Merge branch 'master' into fix/remove-duplicates-rss-bug

This commit is contained in:
Khande Rameshwar 2025-12-11 10:23:07 +05:30 committed by GitHub
commit 48837a9325
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5876 changed files with 509184 additions and 193134 deletions

View File

@ -18,5 +18,4 @@ packages/**/*.test.*
docker/compose
docker/**/Dockerfile
.vscode
packages/testing
cypress
packages/testing

3
.github/CODEOWNERS vendored
View File

@ -1 +1,4 @@
packages/@n8n/db/src/migrations/ @n8n-io/migrations-review
# Node popularity data updates
packages/frontend/editor-ui/data/node-popularity.json @n8n-io/catalysts

View File

@ -85,7 +85,7 @@ body:
id: execution-mode
attributes:
label: Execution mode
description: '[Info](https://docs.n8n.io/hosting/scaling/execution-modes-processes/)'
description: '[Info](https://docs.n8n.io/hosting/scaling/queue-mode/)'
options:
- main (default)
- queue

View File

@ -4,3 +4,5 @@ self-hosted-runner:
- blacksmith-4vcpu-ubuntu-2204
- blacksmith-2vcpu-ubuntu-2204-arm
- blacksmith-4vcpu-ubuntu-2204-arm
- blacksmith-8vcpu-ubuntu-2204
- ubuntu-slim

View File

@ -24,7 +24,7 @@ function generateExperimentalVersion(currentVersion) {
const rootDir = process.cwd();
const releaseType = process.env.RELEASE_TYPE;
assert.match(releaseType, /^(patch|minor|major|experimental)$/, 'Invalid RELEASE_TYPE');
assert.match(releaseType, /^(patch|minor|major|experimental|premajor)$/, 'Invalid RELEASE_TYPE');
// TODO: if releaseType is `auto` determine release type based on the changelog
@ -63,7 +63,9 @@ for (const packageName in packageMap) {
)
? releaseType === 'experimental'
? generateExperimentalVersion(version)
: semver.inc(version, releaseType)
: releaseType === 'premajor'
? semver.inc(version, version.includes('-rc.') ? 'prerelease' : 'premajor', undefined, 'rc')
: semver.inc(version, releaseType)
: version;
await writeFile(packageFile, JSON.stringify(packageJson, null, 2) + '\n');

177
.github/scripts/docker/docker-config.mjs vendored Normal file
View File

@ -0,0 +1,177 @@
#!/usr/bin/env node
import { appendFileSync } from 'node:fs';
class BuildContext {
constructor() {
this.githubOutput = process.env.GITHUB_OUTPUT || null;
}
determine({ event, pr, branch, version, releaseType, pushEnabled }) {
let context = {
version: '',
release_type: '',
platforms: ['linux/amd64', 'linux/arm64'],
push_to_ghcr: true,
push_to_docker: false,
};
if (version && releaseType) {
context.version = version;
context.release_type = releaseType;
context.push_to_docker = true;
} else {
switch (event) {
case 'schedule':
context.version = 'nightly';
context.release_type = 'nightly';
context.push_to_docker = true;
break;
case 'pull_request':
context.version = `pr-${pr}`;
context.release_type = 'dev';
context.push_to_ghcr = false;
break;
case 'workflow_dispatch':
context.version = `branch-${this.sanitizeBranch(branch)}`;
context.release_type = 'branch';
context.platforms = ['linux/amd64'];
break;
case 'push':
if (branch === 'master') {
context.version = 'dev';
context.release_type = 'dev';
context.push_to_docker = true;
} else {
context.version = `branch-${this.sanitizeBranch(branch)}`;
context.release_type = 'branch';
context.platforms = ['linux/amd64'];
}
break;
case 'workflow_call':
case 'release':
if (!version) throw new Error('Version required for release');
context.version = version;
context.release_type = releaseType || 'stable';
context.push_to_docker = true;
break;
default:
throw new Error(`Unknown event: ${event}`);
}
}
// Handle push_enabled override
if (pushEnabled !== undefined) {
context.push_enabled = pushEnabled;
} else {
context.push_enabled = context.push_to_ghcr;
}
return context;
}
sanitizeBranch(branch) {
if (!branch) return 'unknown';
return branch
.toLowerCase()
.replace(/[^a-z0-9._-]/g, '-')
.replace(/^[.-]/, '')
.replace(/[.-]$/, '')
.substring(0, 128);
}
buildMatrix(platforms) {
const runners = {
'linux/amd64': 'blacksmith-4vcpu-ubuntu-2204',
'linux/arm64': 'blacksmith-4vcpu-ubuntu-2204-arm',
};
const matrix = {
platform: [],
include: [],
};
for (const platform of platforms) {
const shortName = platform.split('/').pop(); // amd64 or arm64
matrix.platform.push(shortName);
matrix.include.push({
platform: shortName,
runner: runners[platform],
docker_platform: platform,
});
}
return matrix;
}
output(context, matrix = null) {
const buildMatrix = matrix || this.buildMatrix(context.platforms);
if (this.githubOutput) {
const outputs = [
`version=${context.version}`,
`release_type=${context.release_type}`,
`platforms=${JSON.stringify(context.platforms)}`,
`push_to_ghcr=${context.push_to_ghcr}`,
`push_to_docker=${context.push_to_docker}`,
`push_enabled=${context.push_enabled}`,
`build_matrix=${JSON.stringify(buildMatrix)}`,
];
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
} else {
console.log(JSON.stringify({ ...context, build_matrix: buildMatrix }, null, 2));
}
}
}
// CLI - Simple argument parsing
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const getArg = (name) => {
const index = args.indexOf(`--${name}`);
if (index === -1 || !args[index + 1]) return undefined;
const value = args[index + 1];
// Handle empty strings and 'null' as undefined
return value === '' || value === 'null' ? undefined : value;
};
try {
const context = new BuildContext();
const pushEnabledArg = getArg('push-enabled');
const result = context.determine({
event: getArg('event') || process.env.GITHUB_EVENT_NAME,
pr: getArg('pr') || process.env.GITHUB_PR_NUMBER,
branch: getArg('branch') || process.env.GITHUB_REF_NAME,
version: getArg('version'),
releaseType: getArg('release-type'),
pushEnabled:
pushEnabledArg === 'true' ? true : pushEnabledArg === 'false' ? false : undefined,
});
const matrix = context.buildMatrix(result.platforms);
// Debug output when GITHUB_OUTPUT is set (running in Actions)
if (context.githubOutput) {
console.log('=== Build Context ===');
console.log(`version: ${result.version}`);
console.log(`release_type: ${result.release_type}`);
console.log(`platforms: ${JSON.stringify(result.platforms, null, 2)}`);
console.log(`push_to_ghcr: ${result.push_to_ghcr}`);
console.log(`push_to_docker: ${result.push_to_docker}`);
console.log(`push_enabled: ${result.push_enabled}`);
console.log('build_matrix:', JSON.stringify(matrix, null, 2));
}
context.output(result, matrix);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
export default BuildContext;

113
.github/scripts/docker/docker-tags.mjs vendored Normal file
View File

@ -0,0 +1,113 @@
#!/usr/bin/env node
import { appendFileSync } from 'node:fs';
class TagGenerator {
constructor() {
this.githubOwner = process.env.GITHUB_REPOSITORY_OWNER || 'n8n-io';
this.dockerUsername = process.env.DOCKER_USERNAME || 'n8nio';
this.githubOutput = process.env.GITHUB_OUTPUT || null;
}
generate({ image, version, platform, includeDockerHub = false }) {
let imageName = image;
let versionSuffix = '';
if (image === 'runners-distroless') {
imageName = 'runners';
versionSuffix = '-distroless';
}
const platformSuffix = platform ? `-${platform.split('/').pop()}` : '';
const fullVersion = `${version}${versionSuffix}${platformSuffix}`;
const tags = {
ghcr: [`ghcr.io/${this.githubOwner}/${imageName}:${fullVersion}`],
docker: includeDockerHub ? [`${this.dockerUsername}/${imageName}:${fullVersion}`] : [],
};
tags.all = [...tags.ghcr, ...tags.docker];
return tags;
}
output(tags, prefix = '') {
if (this.githubOutput) {
const prefixStr = prefix ? `${prefix}_` : '';
const primaryTag = tags.ghcr[0] ? tags.ghcr[0].replace(/-amd64$|-arm64$/, '') : '';
const outputs = [
`${prefixStr}tags=${tags.all.join(',')}`,
`${prefixStr}ghcr_tag=${tags.ghcr[0] || ''}`,
`${prefixStr}docker_tag=${tags.docker[0] || ''}`,
`${prefixStr}primary_tag=${primaryTag}`,
];
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
} else {
console.log(JSON.stringify(tags, null, 2));
}
}
generateAll({ version, platform, includeDockerHub = false }) {
const images = ['n8n', 'runners', 'runners-distroless'];
const results = {};
for (const image of images) {
const tags = this.generate({ image, version, platform, includeDockerHub });
const prefix = image.replace('-distroless', '_distroless');
results[prefix] = tags;
if (this.githubOutput) {
this.output(tags, prefix);
}
}
return results;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const getArg = (name) => {
const index = args.indexOf(`--${name}`);
return index !== -1 && args[index + 1] ? args[index + 1] : undefined;
};
const hasFlag = (name) => args.includes(`--${name}`);
try {
const generator = new TagGenerator();
const version = getArg('version');
if (!version) {
console.error('Error: --version is required');
process.exit(1);
}
if (hasFlag('all')) {
const results = generator.generateAll({
version,
platform: getArg('platform'),
includeDockerHub: hasFlag('include-docker'),
});
if (!generator.githubOutput) {
console.log(JSON.stringify(results, null, 2));
}
} else {
const image = getArg('image');
if (!image) {
console.error('Error: Either --image or --all is required');
process.exit(1);
}
const tags = generator.generate({
image,
version,
platform: getArg('platform'),
includeDockerHub: hasFlag('include-docker'),
});
generator.output(tags);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
export default TagGenerator;

View File

@ -3,7 +3,7 @@
"cacheable-lookup": "6.1.0",
"conventional-changelog": "^4.0.0",
"debug": "4.3.4",
"glob": "10.3.10",
"glob": "10.5.0",
"p-limit": "3.1.0",
"picocolors": "1.0.1",
"semver": "7.5.4",

View File

@ -76,6 +76,7 @@ jobs:
working-directory: packages/@n8n/benchmark
- name: Run the benchmark
id: benchmark
env:
BENCHMARK_RESULT_WEBHOOK_URL: ${{ secrets.BENCHMARK_RESULT_WEBHOOK_URL }}
BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER: ${{ secrets.BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER }}
@ -102,3 +103,20 @@ jobs:
if: always()
run: pnpm destroy-cloud-env ${{ env.DEBUG }}
working-directory: packages/@n8n/benchmark
- name: Fail `build` job if `benchmark` step failed
if: steps.benchmark.outcome == 'failure'
run: exit 1
notify-on-failure:
name: Notify Cats on failure
runs-on: ubuntu-latest
needs: [build]
if: failure()
steps:
- uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
with:
status: ${{ job.status }}
channel: '#team-catalysts'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: Benchmark run failed for n8n tag `${{ inputs.n8n_tag || 'nightly' }}` - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

View File

@ -0,0 +1,100 @@
name: Trigger build/unit tests on PR comment
on:
issue_comment:
types: [created]
permissions:
pull-requests: read
contents: read
actions: write
issues: write
jobs:
validate_and_dispatch:
name: Validate user and dispatch CI workflow
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/build-unit-test')
runs-on: ubuntu-latest
steps:
- name: Validate user permissions and collect PR data
id: check_permissions
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const commenter = context.actor;
const body = (context.payload.comment.body || '').trim();
const isCommand = body.startsWith('/build-unit-test');
const allowedPermissions = ['admin', 'write', 'maintain'];
const commentId = context.payload.comment.id;
const { owner, repo } = context.repo;
async function react(content) {
try {
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: commentId,
content,
});
} catch (error) {
console.log(`Failed to add reaction '${content}': ${error.message}`);
}
}
core.setOutput('proceed', 'false');
core.setOutput('headSha', '');
core.setOutput('prNumber', '');
if (!context.payload.issue.pull_request || !isCommand) {
console.log('Comment is not /build-unit-test on a pull request. Skipping.');
return;
}
let permission;
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: commenter,
});
permission = data.permission;
} catch (error) {
console.log(`Could not verify permissions for @${commenter}: ${error.message}`);
await react('confused');
return;
}
if (!allowedPermissions.includes(permission)) {
console.log(`User @${commenter} has '${permission}' permission; requires admin/write/maintain.`);
await react('-1');
return;
}
try {
const prNumber = context.issue.number;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
await react('+1');
core.setOutput('proceed', 'true');
core.setOutput('headSha', pr.head.sha);
core.setOutput('prNumber', String(prNumber));
} catch (error) {
console.log(`Failed to fetch PR details for PR #${context.issue.number}: ${error.message}`);
await react('confused');
}
- name: Dispatch build/unit test workflow
if: ${{ steps.check_permissions.outputs.proceed == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ steps.check_permissions.outputs.headSha }}
PR_NUMBER: ${{ steps.check_permissions.outputs.prNumber }}
run: |
gh workflow run ci-manual-build-unit-tests.yml \
--repo "${{ github.repository }}" \
-f ref="${HEAD_SHA}" \
-f pr_number="${PR_NUMBER}"

81
.github/workflows/build-windows.yml vendored Normal file
View File

@ -0,0 +1,81 @@
name: Windows CI
on:
workflow_dispatch:
inputs:
notify_on_failure:
description: 'Send Slack notification on build failure'
required: false
type: boolean
default: false
workflow_call:
inputs:
notify_on_failure:
description: 'Send Slack notification on build failure'
required: false
type: boolean
default: false
secrets:
QBOT_SLACK_TOKEN:
required: false
pull_request:
branches: [master]
paths:
- '**/package.json'
- '**/turbo.json'
- '.github/workflows/build-windows.yml'
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js and Build
uses: ./.github/actions/setup-nodejs-github
with:
build-command: pnpm build
- name: Send Slack notification on failure
if: failure() && inputs.notify_on_failure == true
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
token: ${{ secrets.QBOT_SLACK_TOKEN }}
payload: |
{
"channel": "C035KBDA917",
"text": "🚨 Windows build failed for `${{ github.repository }}` on branch `${{ github.ref_name }}`",
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": "🚨 Windows Build Failed" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Repository:*\n<${{ github.server_url }}/${{ github.repository }}|${{ github.repository }}>" },
{ "type": "mrkdwn", "text": "*Branch:*\n`${{ github.ref_name }}`" },
{ "type": "mrkdwn", "text": "*Commit:*\n`${{ github.sha }}`" },
{ "type": "mrkdwn", "text": "*Trigger:*\n${{ github.event_name }}" }
]
},
{
"type": "section",
"text": { "type": "mrkdwn", "text": ":warning: *Cross-platform compatibility issue detected*\nThis likely indicates Unix-specific commands in package.json scripts or build configuration that don't work on Windows." }
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": ":github: View Workflow Run" },
"style": "danger",
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}

View File

@ -8,6 +8,7 @@ on:
- synchronize
branches:
- 'master'
- '1.x'
jobs:
check-pr-title:
@ -15,6 +16,6 @@ jobs:
timeout-minutes: 5
steps:
- name: Validate PR title
uses: n8n-io/validate-n8n-pull-request-title@c97ff722ac14ee0bda73766473bba764445db805 # v2.2.0
uses: n8n-io/validate-n8n-pull-request-title@c3b6fd06bda12eebd57a592c0cf3b747d5b73569 # v2.4.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -30,12 +30,14 @@ jobs:
chromatic:
needs: [changeset]
if: |
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request_review' &&
needs.changeset.outputs.has_changes == 'true' &&
github.event.review.state == 'approved' &&
!contains(github.event.pull_request.labels.*.name, 'community')
!startsWith(github.event.pull_request.head.ref, 'release/') &&
!startsWith(github.event.pull_request.head.ref, 'release-pr/')
)
runs-on: blacksmith-2vcpu-ubuntu-2204
steps:
@ -54,6 +56,8 @@ jobs:
continue-on-error: true
with:
workingDir: packages/frontend/@n8n/design-system
autoAcceptChanges: 'master'
skip: 'release/**'
onlyChanged: true
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
exitZeroOnChanges: false

73
.github/workflows/ci-evals.yml vendored Normal file
View File

@ -0,0 +1,73 @@
name: Run Workflow Builder Evals
on:
push:
branches:
- master
paths:
- 'packages/@n8n/ai-workflow-builder.ee/**'
- '.github/workflows/ci-evals.yml'
schedule:
- cron: '0 22 * * 6'
workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to test.'
required: false
default: 'master'
dataset:
description: 'LangSmith dataset to use.'
required: false
default: 'workflow-builder-canvas-prompts'
jobs:
evals:
name: Run Evaluations
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
N8N_AI_ANTHROPIC_KEY: ${{ secrets.EVALS_ANTHROPIC_KEY }}
LANGSMITH_TRACING: true
LANGSMITH_ENDPOINT: ${{ secrets.EVALS_LANGSMITH_ENDPOINT }}
LANGSMITH_API_KEY: ${{ secrets.EVALS_LANGSMITH_API_KEY }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.inputs.branch || github.ref }}
- name: Select dataset
run: |
DATASET="workflow-builder-canvas-prompts"
if [ "${{ github.event_name }}" = "schedule" ]; then
DATASET="prompts-v2"
elif [ -n "${{ github.event.inputs.dataset }}" ]; then
DATASET="${{ github.event.inputs.dataset }}"
fi
echo "LANGSMITH_DATASET_NAME=$DATASET" >> "$GITHUB_ENV"
- name: Setup and Build
uses: ./.github/actions/setup-nodejs-blacksmith
- name: Install uv
uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # 6.5.0
with:
enable-cache: true
- name: Install just
uses: extractions/setup-just@e33e0265a09d6d736e2ee1e0eb685ef1de4669ff # v3.0.0
- name: Install Python
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
run: uv python install 3.11
- name: Install workflow comparison dependencies
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
run: just sync-all
- name: Export Node Types
run: |
./packages/cli/bin/n8n export:nodes --output ./packages/@n8n/ai-workflow-builder.ee/evaluations/nodes.json
- name: Run Evaluations
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations
run: |
pnpm eval:langsmith --repetitions 3

View File

@ -0,0 +1,132 @@
name: Build, unit test and lint (manual trigger)
on:
workflow_dispatch:
inputs:
ref:
description: Commit SHA or ref to check out
required: true
pr_number:
description: PR number (optional, for check reporting)
required: false
type: string
permissions:
contents: read
checks: write
jobs:
create-check-run:
name: Create Check Run
runs-on: ubuntu-latest
if: inputs.pr_number != ''
outputs:
check_run_id: ${{ steps.create.outputs.check_run_id }}
steps:
- name: Create pending check run on PR
id: create
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { data: checkRun } = await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'Build & Unit Tests - Checks',
head_sha: '${{ inputs.ref }}',
status: 'in_progress',
output: {
title: 'Build & Unit Tests - Checks',
summary: 'Running build, unit tests, and lint...'
}
});
core.setOutput('check_run_id', checkRun.id);
console.log(`Created check run ${checkRun.id} on commit ${{ inputs.ref }}`);
install-and-build:
name: Install & Build
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
NODE_OPTIONS: '--max-old-space-size=6144'
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ inputs.ref }}
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
- name: Run format check
run: pnpm format:check
- name: Run typecheck
run: pnpm typecheck
unit-tests:
name: Unit tests
needs: install-and-build
uses: ./.github/workflows/units-tests-reusable.yml
with:
ref: ${{ inputs.ref }}
collectCoverage: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
lint:
name: Lint
needs: install-and-build
uses: ./.github/workflows/linting-reusable.yml
with:
ref: ${{ inputs.ref }}
post-build-unit-tests:
name: Build & Unit Tests - Checks
runs-on: ubuntu-latest
needs: [create-check-run, install-and-build, unit-tests, lint]
if: always()
steps:
- name: Update check run on PR (if triggered from PR comment)
if: inputs.pr_number != ''
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const checkRunId = '${{ needs.create-check-run.outputs.check_run_id }}';
if (!checkRunId) {
console.log('No check run ID found, skipping update');
return;
}
const buildResult = '${{ needs.install-and-build.result }}';
const testResult = '${{ needs.unit-tests.result }}';
const lintResult = '${{ needs.lint.result }}';
const conclusion = (buildResult === 'success' && testResult === 'success' && lintResult === 'success')
? 'success'
: 'failure';
const summary = `
**Build**: ${buildResult}
**Unit Tests**: ${testResult}
**Lint**: ${lintResult}
`;
await github.rest.checks.update({
owner: context.repo.owner,
repo: context.repo.repo,
check_run_id: parseInt(checkRunId),
status: 'completed',
conclusion: conclusion,
output: {
title: 'Build & Unit Tests - Checks',
summary: summary
}
});
console.log(`Updated check run ${checkRunId} with conclusion: ${conclusion}`);
- name: Fail if any job failed
if: needs.install-and-build.result == 'failure' || needs.unit-tests.result == 'failure' || needs.lint.result == 'failure'
run: exit 1

View File

@ -4,6 +4,7 @@ on:
push:
branches:
- master
- 1.x
paths-ignore:
- packages/@n8n/task-runner-python/**
@ -48,4 +49,4 @@ jobs:
status: ${{ job.status }}
channel: '#alerts-build'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: Master branch (build or test or lint) failed (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
message: ${{ github.ref_name }} branch (build or test or lint) failed (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})

View File

@ -16,8 +16,6 @@ on:
- packages/cli/**/__tests__/**
- .github/workflows/ci-postgres-mysql.yml
- .github/docker-compose.yml
pull_request_review:
types: [submitted]
concurrency:
group: db-${{ github.event.pull_request.number || github.ref }}
@ -59,7 +57,8 @@ jobs:
name: MariaDB
needs: build
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 20
timeout-minutes: 30
if: false
env:
DB_MYSQLDB_PASSWORD: password
DB_MYSQLDB_POOL_SIZE: 1
@ -89,6 +88,7 @@ jobs:
needs: build
runs-on: blacksmith-2vcpu-ubuntu-2204
timeout-minutes: 20
if: false
env:
DB_MYSQLDB_PASSWORD: password
DB_MYSQLDB_POOL_SIZE: 1
@ -109,7 +109,8 @@ jobs:
- name: Test MySQL
working-directory: packages/cli
run: pnpm test:mysql --testTimeout 120000
# We sleep here due to flakiness with DB tests if we connect to the database too soon
run: sleep 2s && pnpm test:mysql --testTimeout 120000
postgres:
name: Postgres
@ -139,7 +140,7 @@ jobs:
notify-on-failure:
name: Notify Slack on failure
runs-on: ubuntu-latest
needs: [sqlite-pooled, mariadb, postgres, mysql]
needs: [sqlite-pooled, postgres]
steps:
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0

View File

@ -2,9 +2,13 @@ name: Build, unit test and lint branch
on:
pull_request:
branches:
- '**'
- '!release/*'
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
COVERAGE_ENABLED: 'true' # Set globally for all jobs - ensures Turbo cache consistency
jobs:
install-and-build:
@ -16,7 +20,7 @@ jobs:
frontend_changed: ${{ steps.paths-filter.outputs.frontend == 'true' }}
non_python_changed: ${{ steps.paths-filter.outputs.non-python == 'true' }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
@ -43,10 +47,6 @@ jobs:
if: steps.paths-filter.outputs.non-python == 'true'
run: pnpm format:check
- name: Run typecheck
if: steps.paths-filter.outputs.non-python == 'true'
run: pnpm typecheck
- name: Upload Frontend Build Artifacts
if: steps.paths-filter.outputs.frontend == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@ -61,7 +61,7 @@ jobs:
if: needs.install-and-build.outputs.frontend_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
@ -97,6 +97,21 @@ jobs:
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
typecheck:
name: Typecheck
if: needs.install-and-build.outputs.non_python_changed == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: install-and-build
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- name: Setup Node.js
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
with:
build-command: pnpm typecheck
lint:
name: Lint
if: needs.install-and-build.outputs.non_python_changed == 'true'
@ -104,3 +119,27 @@ jobs:
needs: install-and-build
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
e2e-tests:
name: E2E Tests
needs: [install-and-build]
if: needs.install-and-build.outputs.non_python_changed == 'true'
uses: ./.github/workflows/playwright-test-ci.yml
secrets: inherit
# This job is required by GitHub branch protection rules.
# PRs cannot be merged unless this job passes.
# If you add/remove jobs that should block merging, update the 'needs' array below.
required-checks:
name: Required Checks
needs: [install-and-build, unit-test, typecheck, lint, e2e-tests]
if: always()
runs-on: ubuntu-slim
steps:
- name: Fail if any required job failed or was skipped unexpectedly
# The non_python_changed check allows jobs to be skipped for python-only changes,
# since those jobs don't run when only python files are modified.
if: |
contains(needs.*.result, 'failure') ||
(needs.install-and-build.outputs.non_python_changed == 'true' && contains(needs.*.result, 'skipped'))
run: exit 1

View File

@ -0,0 +1,56 @@
name: AI Workflow Builder Evals Python CI
on:
pull_request:
paths:
- packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/**
- .github/workflows/ci-python-workflow-builder-evals.yml
push:
paths:
- packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/**
jobs:
workflow-comparison:
name: Workflow Comparison Python
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
steps:
- name: Check out project
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install uv
uses: astral-sh/setup-uv@d9e0f98d3fc6adb07d1e3d37f3043649ddad06a1 # 6.5.0
with:
enable-cache: true
- name: Install just
uses: extractions/setup-just@e33e0265a09d6d736e2ee1e0eb685ef1de4669ff # v3.0.0
- name: Install Python
run: uv python install 3.11
- name: Install project dependencies
run: just sync-all
- name: Format check
run: just format-check
- name: Typecheck
run: just typecheck
- name: Lint
run: just lint
- name: Python unit tests
run: uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
- name: Upload coverage to Codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/coverage.xml
flags: tests
name: workflow-comparison-python
fail_ci_if_error: false

View File

@ -35,7 +35,7 @@ jobs:
run: just sync-all
- name: Format check
run: just format check
run: just format-check
- name: Typecheck
run: just typecheck

84
.github/workflows/data-tooling.yml vendored Normal file
View File

@ -0,0 +1,84 @@
name: Data tooling, test exports and imports of data to various db types
# TODO: Uncomment this after it works on a manual invocation
# on:
# pull_request:
# branches:
# - '**'
# - '!release/*'
on:
workflow_dispatch:
jobs:
sqlite-export-sqlite-import:
name: sqlite database export -> sqlite database import
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
db_changed: ${{ steps.paths-filter.outputs.db == 'true' }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# with:
# ref: refs/pull/${{ github.event.pull_request.number }}/merge
- name: Check for frontend changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: paths-filter
with:
filters: |
db:
- packages/@n8n/db/**
- packages/cli/**
- name: Setup, build and export sqlite
if: steps.paths-filter.outputs.db == 'true'
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
with:
build-command: |
pnpm build
./packages/cli/bin/n8n export:entities --outputDir packages/cli/commands/export/outputs
./packages/cli/bin/n8n import:entities --inputDir packages/cli/commands/export/outputs --truncateTables
postgres-export-postgres-import:
name: postgres export -> postgres import
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
db_changed: ${{ steps.paths-filter.outputs.db == 'true' }}
env:
DB_TYPE: postgresdb
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_HOST: localhost
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_USER: postgres
DB_POSTGRESDB_PASSWORD: password
DB_POSTGRESDB_POOL_SIZE: 1 # Detect connection pooling deadlocks
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# with:
# ref: refs/pull/${{ github.event.pull_request.number }}/merge
- name: Check for db changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: paths-filter
with:
filters: |
db:
- packages/@n8n/db/**
- packages/cli/**
- name: Setup and Build
if: steps.paths-filter.outputs.db == 'true'
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
- name: Start Postgres
if: steps.paths-filter.outputs.db == 'true'
uses: isbang/compose-action@802a148945af6399a338c7906c267331b39a71af # v2.0.0
with:
compose-file: ./.github/docker-compose.yml
services: |
postgres
- name: Export postgres
if: steps.paths-filter.outputs.db == 'true'
run: ./packages/cli/bin/n8n export:entities --outputDir packages/cli/commands/export/outputs
- name: Import postgres
if: steps.paths-filter.outputs.db == 'true'
run: ./packages/cli/bin/n8n import:entities --inputDir packages/cli/commands/export/outputs --truncateTables

View File

@ -9,13 +9,20 @@ on:
pull_request:
paths:
- 'docker/images/n8n-base/Dockerfile'
workflow_dispatch:
inputs:
push:
description: 'Push to registries'
required: false
default: false
type: boolean
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node_version: ['20', '22', '24']
node_version: ['20', '22.21.1', '24']
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -26,7 +33,7 @@ jobs:
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Login to GitHub Container Registry
if: github.event_name == 'push'
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true)
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
@ -34,6 +41,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true)
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
@ -47,13 +55,12 @@ jobs:
build-args: |
NODE_VERSION=${{ matrix.node_version }}
platforms: linux/amd64,linux/arm64
provenance: ${{ github.event_name == 'push' }}
sbom: ${{ github.event_name == 'push' }}
push: ${{ github.event_name == 'push' }}
provenance: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
sbom: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
push: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.push == true) }}
tags: |
${{ secrets.DOCKER_USERNAME }}/base:${{ matrix.node_version }}-${{ github.sha }}
${{ secrets.DOCKER_USERNAME }}/base:${{ matrix.node_version }}
ghcr.io/${{ github.repository_owner }}/base:${{ matrix.node_version }}-${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/base:${{ matrix.node_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
no-cache: true

View File

@ -1,14 +1,13 @@
# This workflow is used to build and push the Docker image for n8nio/n8n and n8nio/runners
# - determine-build-context: Determines what needs to be built based on the trigger
# - build-and-push-docker: This builds on both an ARM64 and AMD64 runner so the builds are native to the platform. Uses blacksmith native runners and build-push-action
# - create_multi_arch_manifest: This creates the multi-arch manifest for the Docker image. Needed to recombine the images from the build-and-push-docker job since they are separate runners.
# - security-scan: This scans the n8nio/n8n Docker image for security vulnerabilities using Trivy.
# - security-scan-runners: This scans the n8nio/runners Docker image for security vulnerabilities using Trivy.
#
# - Uses docker-config.mjs for context determination, this determines what needs to be built based on the trigger
# - Uses docker-tags.mjs for tag generation, this generates the tags for the images
name: 'Docker: Build and Push'
env:
NODE_OPTIONS: '--max-old-space-size=7168'
NODE_VERSION: '22.21.1'
on:
schedule:
@ -43,134 +42,43 @@ on:
required: false
type: string
pull_request:
types:
- opened
- ready_for_review
paths:
- '.github/workflows/docker-build-push.yml'
- 'docker/images/n8n/Dockerfile'
- 'docker/images/runners/Dockerfile'
jobs:
determine-build-context:
name: Determine Build Context
runs-on: ubuntu-latest
outputs:
release_type: ${{ steps.context.outputs.release_type }}
n8n_version: ${{ steps.context.outputs.n8n_version }}
n8n_version: ${{ steps.context.outputs.version }}
push_enabled: ${{ steps.context.outputs.push_enabled }}
build_matrix: ${{ steps.matrix.outputs.matrix }}
push_to_docker: ${{ steps.context.outputs.push_to_docker }}
build_matrix: ${{ steps.context.outputs.build_matrix }}
steps:
- name: Determine build context values
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Determine build context
id: context
run: |
# Debug info
echo "Event: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }}"
echo "Ref Name: ${{ github.ref_name }}"
# Check if called by another workflow (has n8n_version input)
if [[ -n "${{ inputs.n8n_version }}" ]]; then
# workflow_call - used for releases
{
echo "release_type=${{ inputs.release_type }}"
echo "n8n_version=${{ inputs.n8n_version }}"
echo "push_enabled=${{ inputs.push_enabled }}"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
# Nightly builds
{
echo "release_type=nightly"
echo "n8n_version=snapshot"
echo "push_enabled=true"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Build branches for Nathan deploy
BRANCH_NAME="${{ github.ref_name }}"
# Fallback to parsing ref if ref_name is empty
if [[ -z "$BRANCH_NAME" ]] && [[ "${{ github.ref }}" =~ ^refs/heads/(.+)$ ]]; then
BRANCH_NAME="${BASH_REMATCH[1]}"
fi
# Sanitize branch name for Docker tag
SAFE_BRANCH_NAME=$(echo "$BRANCH_NAME" | tr '/' '-' | tr -cd '[:alnum:]-_')
if [[ -z "$SAFE_BRANCH_NAME" ]]; then
echo "Error: Could not determine valid branch name"
exit 1
fi
{
echo "release_type=branch"
echo "n8n_version=branch-${SAFE_BRANCH_NAME}"
echo "push_enabled=${{ inputs.push_enabled }}"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
# Direct PR triggers for testing Dockerfile changes
{
echo "release_type=dev"
echo "n8n_version=pr-${{ github.event.pull_request.number }}"
echo "push_enabled=false"
} >> "$GITHUB_OUTPUT"
fi
# Output summary for logs
echo "=== Build Context Summary ==="
echo "Release type: $(grep release_type "$GITHUB_OUTPUT" | cut -d= -f2)"
echo "N8N version: $(grep n8n_version "$GITHUB_OUTPUT" | cut -d= -f2)"
echo "Push enabled: $(grep push_enabled "$GITHUB_OUTPUT" | cut -d= -f2)"
- name: Determine build matrix
id: matrix
run: |
RELEASE_TYPE="${{ steps.context.outputs.release_type }}"
# Branch builds only need AMD64, everything else needs both platforms
if [[ "$RELEASE_TYPE" == "branch" ]]; then
MATRIX='{
"platform": ["amd64"],
"include": [{
"platform": "amd64",
"runner": "blacksmith-4vcpu-ubuntu-2204",
"docker_platform": "linux/amd64"
}]
}'
else
# All other builds (stable, nightly, dev, PR) need both platforms
MATRIX='{
"platform": ["amd64", "arm64"],
"include": [{
"platform": "amd64",
"runner": "blacksmith-4vcpu-ubuntu-2204",
"docker_platform": "linux/amd64"
}, {
"platform": "arm64",
"runner": "blacksmith-4vcpu-ubuntu-2204-arm",
"docker_platform": "linux/arm64"
}]
}'
fi
# Output matrix as single line for GITHUB_OUTPUT
echo "matrix=$(echo "$MATRIX" | jq -c .)" >> "$GITHUB_OUTPUT"
echo "Build matrix: $(echo "$MATRIX" | jq .)"
node .github/scripts/docker/docker-config.mjs \
--event "${{ github.event_name }}" \
--pr "${{ github.event.pull_request.number }}" \
--branch "${{ github.ref_name }}" \
--version "${{ inputs.n8n_version }}" \
--release-type "${{ inputs.release_type }}" \
--push-enabled "${{ inputs.push_enabled }}"
build-and-push-docker:
name: Build App, then Build and Push Docker Image (${{ matrix.platform }})
needs: determine-build-context
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
timeout-minutes: 25
strategy:
matrix: ${{ fromJSON(needs.determine-build-context.outputs.build_matrix) }}
outputs:
image_ref: ${{ steps.determine-tags.outputs.primary_ghcr_manifest_tag }}
primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.primary_ghcr_manifest_tag }}
runners_primary_ghcr_manifest_tag: ${{ steps.determine-runners-tags.outputs.primary_ghcr_manifest_tag }}
image_ref: ${{ steps.determine-tags.outputs.n8n_primary_tag }}
primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.n8n_primary_tag }}
runners_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_primary_tag }}
runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_distroless_primary_tag }}
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -178,153 +86,23 @@ jobs:
fetch-depth: 0
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
uses: ./.github/actions/setup-nodejs-blacksmith
with:
build-command: pnpm build:n8n
- name: Determine Docker tags
- name: Determine Docker tags for all images
id: determine-tags
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
N8N_VERSION_TAG="${{ needs.determine-build-context.outputs.n8n_version }}"
GHCR_BASE="ghcr.io/${{ github.repository_owner }}/n8n"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/n8n"
PLATFORM="${{ matrix.platform }}"
node .github/scripts/docker/docker-tags.mjs \
--all \
--version "${{ needs.determine-build-context.outputs.n8n_version }}" \
--platform "${{ matrix.docker_platform }}" \
${{ needs.determine-build-context.outputs.push_to_docker == 'true' && '--include-docker' || '' }}
GHCR_TAGS_FOR_PUSH=""
DOCKER_TAGS_FOR_PUSH=""
PRIMARY_GHCR_MANIFEST_TAG_VALUE=""
# Validate inputs
if [[ "$RELEASE_TYPE" == "stable" && -z "$N8N_VERSION_TAG" ]]; then
echo "Error: N8N_VERSION_TAG is empty for a stable release."
exit 1
fi
if [[ "$RELEASE_TYPE" == "branch" && -z "$N8N_VERSION_TAG" ]]; then
echo "Error: N8N_VERSION_TAG is empty for a branch release."
exit 1
fi
# Determine tags based on release type
case "$RELEASE_TYPE" in
"stable")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:${N8N_VERSION_TAG}-${PLATFORM}"
;;
"nightly")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:nightly"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:nightly-${PLATFORM}"
;;
"branch")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
# No Docker Hub tags for branch builds
DOCKER_TAGS_FOR_PUSH=""
;;
"dev"|*)
if [[ "$N8N_VERSION_TAG" == pr-* ]]; then
# PR builds only go to GHCR
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH=""
else
# Regular dev builds go to both registries
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:dev"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:dev-${PLATFORM}"
fi
;;
esac
# Combine all tags
ALL_TAGS="${GHCR_TAGS_FOR_PUSH}"
if [[ -n "$DOCKER_TAGS_FOR_PUSH" ]]; then
ALL_TAGS="${ALL_TAGS}\n${DOCKER_TAGS_FOR_PUSH}"
fi
echo "Generated Tags for push: $ALL_TAGS"
{
echo "tags<<EOF"
echo -e "$ALL_TAGS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
{
echo "ghcr_platform_tag=${GHCR_TAGS_FOR_PUSH}"
echo "dockerhub_platform_tag=${DOCKER_TAGS_FOR_PUSH}"
} >> "$GITHUB_OUTPUT"
# Only output manifest tags from the first platform to avoid duplicates
if [[ "$PLATFORM" == "amd64" ]]; then
echo "primary_ghcr_manifest_tag=${PRIMARY_GHCR_MANIFEST_TAG_VALUE}" >> "$GITHUB_OUTPUT"
fi
- name: Determine Docker tags (runners)
id: determine-runners-tags
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
N8N_VERSION_TAG="${{ needs.determine-build-context.outputs.n8n_version }}"
GHCR_BASE="ghcr.io/${{ github.repository_owner }}/runners"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/runners"
PLATFORM="${{ matrix.platform }}"
GHCR_TAGS_FOR_PUSH=""
DOCKER_TAGS_FOR_PUSH=""
PRIMARY_GHCR_MANIFEST_TAG_VALUE=""
case "$RELEASE_TYPE" in
"stable")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:${N8N_VERSION_TAG}-${PLATFORM}"
;;
"nightly")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:nightly"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:nightly-${PLATFORM}"
;;
"branch")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="" # mirror n8n logic: no Docker Hub for branch
;;
"dev"|*)
if [[ "$N8N_VERSION_TAG" == pr-* ]]; then
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH=""
else
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:dev"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:dev-${PLATFORM}"
fi
;;
esac
ALL_TAGS="${GHCR_TAGS_FOR_PUSH}"
if [[ -n "$DOCKER_TAGS_FOR_PUSH" ]]; then
ALL_TAGS="${ALL_TAGS}\n${DOCKER_TAGS_FOR_PUSH}"
fi
{
echo "tags<<EOF"
echo -e "$ALL_TAGS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
{
echo "ghcr_platform_tag=${GHCR_TAGS_FOR_PUSH}"
echo "dockerhub_platform_tag=${DOCKER_TAGS_FOR_PUSH}"
} >> "$GITHUB_OUTPUT"
# Only output manifest tags from the first platform to avoid duplicates
if [[ "$PLATFORM" == "amd64" ]]; then
echo "primary_ghcr_manifest_tag=${PRIMARY_GHCR_MANIFEST_TAG_VALUE}" >> "$GITHUB_OUTPUT"
fi
echo "=== Generated Docker Tags ==="
cat "$GITHUB_OUTPUT" | grep "_tags=" | while IFS='=' read -r key value; do
echo "${key}: ${value%%,*}..." # Show first tag for brevity
done
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
@ -338,9 +116,9 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: needs.determine-build-context.outputs.push_enabled == 'true' && (
steps.determine-tags.outputs.dockerhub_platform_tag != '' ||
steps.determine-runners-tags.outputs.dockerhub_platform_tag != '')
if: |
needs.determine-build-context.outputs.push_enabled == 'true' &&
needs.determine-build-context.outputs.push_to_docker == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
@ -352,30 +130,44 @@ jobs:
context: .
file: ./docker/images/n8n/Dockerfile
build-args: |
NODE_VERSION=22
NODE_VERSION=${{ env.NODE_VERSION }}
N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }}
N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }}
platforms: ${{ matrix.docker_platform }}
provenance: true
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.tags }}
tags: ${{ steps.determine-tags.outputs.n8n_tags }}
- name: Build and push task runners Docker image
- name: Build and push task runners Docker image (Alpine)
uses: useblacksmith/build-push-action@574eb0ee0b59c6a687ace24192f0727dfb65d6d7 # v1.2
with:
context: .
file: ./docker/images/runners/Dockerfile
build-args: |
NODE_VERSION=22.19
PYTHON_VERSION=3.13
NODE_VERSION=${{ env.NODE_VERSION }}
N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }}
N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }}
platforms: ${{ matrix.docker_platform }}
provenance: true
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-runners-tags.outputs.tags }}
tags: ${{ steps.determine-tags.outputs.runners_tags }}
- name: Build and push task runners Docker image (distroless)
uses: useblacksmith/build-push-action@574eb0ee0b59c6a687ace24192f0727dfb65d6d7 # v1.2
with:
context: .
file: ./docker/images/runners/Dockerfile.distroless
build-args: |
NODE_VERSION=${{ env.NODE_VERSION }}
N8N_VERSION=${{ needs.determine-build-context.outputs.n8n_version }}
N8N_RELEASE_TYPE=${{ needs.determine-build-context.outputs.release_type }}
platforms: ${{ matrix.docker_platform }}
provenance: true
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.runners_distroless_tags }}
create_multi_arch_manifest:
name: Create Multi-Arch Manifest
@ -395,149 +187,70 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine Docker Hub manifest tag
id: dockerhub_check
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
N8N_VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/n8n"
# Determine if Docker Hub manifest is needed and construct the tag
case "$RELEASE_TYPE" in
"stable")
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:${N8N_VERSION}"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
;;
"nightly")
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:nightly"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
;;
"dev")
if [[ "$N8N_VERSION" != pr-* ]]; then
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:dev"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
else
echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT"
fi
;;
*)
echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT"
;;
esac
- name: Determine Docker Hub manifest tag (runners)
id: dockerhub_runners_check
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
N8N_VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/runners"
# Determine if Docker Hub manifest is needed and construct the tag
case "$RELEASE_TYPE" in
"stable")
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:${N8N_VERSION}"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
;;
"nightly")
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:nightly"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
;;
"dev")
if [[ "$N8N_VERSION" != pr-* ]]; then
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:dev"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
else
echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT"
fi
;;
*)
echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT"
;;
esac
- name: Login to Docker Hub
if: steps.dockerhub_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' ||
steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true'
if: needs.determine-build-context.outputs.push_to_docker == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create GHCR multi-arch manifest
if: needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag != ''
- name: Create GHCR multi-arch manifests
run: |
MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}"
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
echo "Creating GHCR manifest: $MANIFEST_TAG"
# Function to create manifest for an image
create_manifest() {
local IMAGE_NAME=$1
local MANIFEST_TAG=$2
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64
else
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
fi
if [[ -z "$MANIFEST_TAG" ]]; then
echo "Skipping $IMAGE_NAME - no manifest tag"
return
fi
- name: Create GHCR multi-arch manifest (runners)
if: needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag != ''
echo "Creating GHCR manifest for $IMAGE_NAME: $MANIFEST_TAG"
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
docker buildx imagetools create \
--tag "$MANIFEST_TAG" \
"${MANIFEST_TAG}-amd64"
else
docker buildx imagetools create \
--tag "$MANIFEST_TAG" \
"${MANIFEST_TAG}-amd64" \
"${MANIFEST_TAG}-arm64"
fi
}
# Create manifests for all images
create_manifest "n8n" "${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}"
create_manifest "runners" "${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}"
create_manifest "runners-distroless" "${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}"
- name: Create Docker Hub manifests
if: needs.determine-build-context.outputs.push_to_docker == 'true'
run: |
MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}"
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}"
echo "Creating GHCR runners manifest: $MANIFEST_TAG"
# Create manifests for each image type
declare -A images=(
["n8n"]="${VERSION}"
["runners"]="${VERSION}"
["runners-distroless"]="${VERSION}-distroless"
)
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
for image in "${!images[@]}"; do
TAG_SUFFIX="${images[$image]}"
IMAGE_NAME="${image//-distroless/}" # Remove -distroless from image name
echo "Creating Docker Hub manifest for $image"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64
else
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
fi
- name: Create Docker Hub multi-arch manifest
if: steps.dockerhub_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true'
run: |
MANIFEST_TAG="${{ steps.dockerhub_check.outputs.DOCKER_MANIFEST_TAG }}"
echo "Creating Docker Hub manifest: $MANIFEST_TAG"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
- name: Create Docker Hub multi-arch manifest (runners)
if: steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true'
run: |
MANIFEST_TAG="${{ steps.dockerhub_runners_check.outputs.DOCKER_MANIFEST_TAG }}"
echo "Creating Docker Hub manifest: $MANIFEST_TAG"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
--tag "${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}" \
"${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-amd64" \
"${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-arm64"
done
call-success-url:
name: Call Success URL
@ -560,7 +273,8 @@ jobs:
if: |
success() &&
(needs.determine-build-context.outputs.release_type == 'stable' ||
needs.determine-build-context.outputs.release_type == 'nightly')
needs.determine-build-context.outputs.release_type == 'nightly' ||
needs.determine-build-context.outputs.release_type == 'rc')
uses: ./.github/workflows/security-trivy-scan-callable.yml
with:
image_ref: ${{ needs.build-and-push-docker.outputs.image_ref }}
@ -568,12 +282,26 @@ jobs:
security-scan-runners:
name: Security Scan (runners)
needs: [determine-build-context, build-and-push-docker]
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
success() &&
(needs.determine-build-context.outputs.release_type == 'stable' ||
needs.determine-build-context.outputs.release_type == 'nightly')
needs.determine-build-context.outputs.release_type == 'nightly' ||
needs.determine-build-context.outputs.release_type == 'rc')
uses: ./.github/workflows/security-trivy-scan-callable.yml
with:
image_ref: ${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}
secrets: inherit
notify-on-failure:
name: Notify Cats on nightly build failure
runs-on: ubuntu-latest
needs: [build-and-push-docker]
if: needs.build-and-push-docker.result == 'failure' && github.event_name == 'schedule'
steps:
- uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
with:
status: ${{ needs.build-and-push-docker.result }}
channel: '#team-catalysts'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: Nightly Docker build failed - ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

View File

@ -1,125 +0,0 @@
name: Reusable e2e workflow
on:
workflow_call:
inputs:
branch:
description: 'GitHub branch to test.'
required: false
type: string
user:
description: 'User who kicked this off.'
required: false
type: string
default: 'schedule'
spec:
description: 'Specify specs.'
required: false
default: 'e2e/*'
type: string
parallel:
description: 'Run tests in parallel.'
required: false
default: true
type: boolean
containers:
description: 'Number of containers to run tests in.'
required: false
default: '[1, 2, 3, 4, 5, 6]'
type: string
pr_number:
description: 'PR number to run tests for.'
required: false
type: number
secrets:
CURRENTS_RECORD_KEY:
description: 'Currents record key.'
required: true
env:
NODE_OPTIONS: --max-old-space-size=3072
jobs:
testing:
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
dashboardUrl: ${{ steps.cypress.outputs.dashboardUrl }}
strategy:
fail-fast: false
matrix:
# If spec is not e2e/* then we run only one container to prevent
# running the same tests multiple times
containers: ${{ fromJSON( inputs.spec == 'e2e/*' && inputs.containers || '[1]' ) }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up and build
uses: ./.github/actions/setup-nodejs-blacksmith
- name: Install Cypress
working-directory: cypress
run: pnpm cypress:install
- name: Cypress run
id: cypress
uses: cypress-io/github-action@be1bab96b388bbd9ce3887e397d373c8557e15af # v6.9.2
with:
working-directory: cypress
install: false
start: pnpm start
wait-on: 'http://localhost:5678'
wait-on-timeout: 120
record: false
spec: ${{ inputs.spec == 'e2e/*' && format('e2e/group{0}/**/*.cy.ts', matrix.containers) || inputs.spec }}
env:
NODE_OPTIONS: --dns-result-order=ipv4first
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
E2E_TESTS: true
COMMIT_INFO_MESSAGE: 🌳 ${{ inputs.branch }} 🤖 ${{ inputs.user }} 🗃️ ${{ inputs.spec }}
SHELL: /bin/sh
- name: Upload test results artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-results-${{ matrix.containers }}
path: cypress/test-results-*.xml
upload-to-currents:
needs: testing
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Download all test results
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: test-results
- name: Merge and upload to Currents
run: |
npm install -g @currents/cmd junit-report-merger
# Merge all XML files, so Currents can show a single view for Cypress
jrm combined-results.xml "test-results/**/test-results-*.xml"
- name: Upload merged XML as artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: merged-junit-results
path: combined-results.xml
- name: Convert and upload to Currents
run: |
currents convert \
--input-format=junit \
--input-file=combined-results.xml \
--output-dir=.currents \
--framework=node \
--framework-version=cypress-14.4.0
currents upload \
--project-id=I0yzoc \
--key=${{ secrets.CURRENTS_RECORD_KEY }} \
--ci-build-id=n8n-io/n8n-${{ github.run_id }}-${{ github.run_attempt }} \
--report-dir=.currents \
--tag=cypress

View File

@ -1,44 +0,0 @@
name: PR E2E
on:
pull_request_review:
types: [submitted]
concurrency:
group: e2e-${{ github.event.pull_request.number || github.ref }}-${{github.event.review.state}}
cancel-in-progress: true
jobs:
eligibility_check:
name: Check Eligibility for Test Run
if: github.event.review.state == 'approved'
uses: ./.github/workflows/check-run-eligibility.yml
with:
is_pr_approved_by_maintainer: true
run-e2e-tests:
name: E2E
uses: ./.github/workflows/e2e-reusable.yml
needs: [eligibility_check]
if: needs.eligibility_check.outputs.should_run == 'true'
with:
pr_number: ${{ github.event.pull_request.number }}
user: ${{ github.event.pull_request.user.login || 'PR User' }}
secrets: inherit
run-playwright-tests:
name: Playwright
uses: ./.github/workflows/playwright-test-reusable.yml
needs: [eligibility_check]
if: needs.eligibility_check.outputs.should_run == 'true'
secrets: inherit
post-e2e-tests:
name: E2E - Checks
runs-on: ubuntu-latest
needs: [eligibility_check, run-e2e-tests, run-playwright-tests]
if: always() && needs.eligibility_check.result != 'skipped'
steps:
- name: Fail if tests failed
if: needs.run-e2e-tests.result == 'failure' || needs.run-playwright-tests.result == 'failure'
run: exit 1

View File

@ -1,78 +0,0 @@
name: End-to-End tests
run-name: E2E Tests ${{ inputs.branch }} - ${{ inputs.user }}
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to test.'
required: false
default: 'master'
spec:
description: 'Specify specs.'
required: false
default: 'e2e/*'
type: string
user:
description: 'User who kicked this off.'
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 workflow is done.'
required: false
default: ''
jobs:
calls-start-url:
name: Calls start URL
runs-on: ubuntu-latest
if: ${{ github.event.inputs.start-url != '' }}
steps:
- name: Calls start URL
env:
START_URL: ${{ github.event.inputs.start-url }}
run: |
[[ "${{ env.START_URL }}" != "" ]] && curl -v -X POST -d 'url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' "${{ env.START_URL }}" || echo ""
shell: bash
run-e2e-tests:
name: E2E
uses: ./.github/workflows/e2e-reusable.yml
with:
branch: ${{ github.event.inputs.branch || 'master' }}
user: ${{ github.event.inputs.user || 'PR User' }}
spec: ${{ github.event.inputs.spec || 'e2e/*' }}
secrets: inherit
run-playwright-tests:
name: Playwright
uses: ./.github/workflows/playwright-test-reusable.yml
secrets: inherit
calls-success-url-notify:
name: Calls success URL and notifies
runs-on: ubuntu-latest
needs: [run-e2e-tests, run-playwright-tests]
if: ${{ github.event.inputs.success-url != '' }}
steps:
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()
with:
status: ${{ job.status }}
channel: '#alerts-build'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: E2E failure for branch `${{ inputs.branch || 'master' }}` deployed by ${{ inputs.user || 'schedule' }} (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
- name: Call Success URL - optionally
env:
SUCCESS_URL: ${{ github.event.inputs.success-url }}
run: |
[[ "${{ env.SUCCESS_URL }}" != "" ]] && curl -v "${{ env.SUCCESS_URL }}" || echo ""
shell: bash

View File

@ -7,7 +7,7 @@ on:
description: GitHub ref to lint.
required: false
type: string
default: master
default: ''
nodeVersion:
description: Version of node to use.
required: false
@ -22,7 +22,7 @@ jobs:
name: Lint
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}

View File

@ -0,0 +1,55 @@
name: E2E Tests for CI
on:
workflow_call:
jobs:
# Multi-main: postgres + redis + caddy + 2 mains + 1 worker
# Only runs for internal PRs (not community/fork PRs)
multi-main-ui:
name: 'Multi-Main: UI'
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: ./.github/workflows/playwright-test-reusable.yml
with:
test-mode: docker-build
test-command: pnpm --filter=n8n-playwright test:container:multi-main:ui
shards: '[1, 2, 3, 4, 5, 6, 7, 8]'
runner: blacksmith-4vcpu-ubuntu-2204
workers: '1'
secrets: inherit
multi-main-isolated:
name: 'Multi-Main: Isolated'
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: ./.github/workflows/playwright-test-reusable.yml
with:
test-mode: docker-build
test-command: pnpm --filter=n8n-playwright test:container:multi-main:isolated
shards: '[1]'
runner: blacksmith-4vcpu-ubuntu-2204
workers: '1'
secrets: inherit
# Community PR tests: Local mode with SQLite (no container building, no secrets required)
# Runs on GitHub-hosted runners without Currents reporting
community-ui:
name: 'Community: UI'
if: ${{ github.event.pull_request.head.repo.fork }}
uses: ./.github/workflows/playwright-test-reusable.yml
with:
test-mode: local
test-command: pnpm --filter=n8n-playwright test:local
shards: '[1, 2, 3, 4, 5]'
runner: ubuntu-latest
workers: '2'
community-isolated:
name: 'Community: Isolated'
if: ${{ github.event.pull_request.head.repo.fork }}
uses: ./.github/workflows/playwright-test-reusable.yml
with:
test-mode: local
test-command: pnpm --filter=n8n-playwright test:local:isolated
shards: '[1]'
runner: ubuntu-latest
workers: '1'

View File

@ -0,0 +1,68 @@
name: Weekly Coverage Tests
on:
schedule:
- cron: '0 2 * * 1' # Every Monday at 2 AM
workflow_dispatch: # Allow manual triggering
env:
PLAYWRIGHT_BROWSERS_PATH: packages/testing/playwright/ms-playwright-cache
NODE_OPTIONS: --max-old-space-size=16384
TESTCONTAINERS_RYUK_DISABLED: true
PLAYWRIGHT_WORKERS: 4
jobs:
coverage:
runs-on: blacksmith-8vcpu-ubuntu-2204
name: Coverage Tests
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Setup Environment
uses: ./.github/actions/setup-nodejs-blacksmith
env:
INCLUDE_TEST_CONTROLLER: 'true'
- name: Build Docker Image with Coverage
run: pnpm build:docker:coverage
env:
INCLUDE_TEST_CONTROLLER: 'true'
- name: Install Browsers
run: pnpm turbo install-browsers:ci
- name: Run Container Coverage Tests
id: coverage-tests
run: |
pnpm --filter n8n-playwright test:container:standard \
--workers=${{ env.PLAYWRIGHT_WORKERS }}
env:
BUILD_WITH_COVERAGE: 'true'
CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }}
QA_PERFORMANCE_METRICS_WEBHOOK_URL: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_URL }}
QA_PERFORMANCE_METRICS_WEBHOOK_USER: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_USER }}
QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD }}
- name: Generate Coverage Report
if: always() && steps.coverage-tests.outcome != 'skipped'
run: pnpm --filter n8n-playwright coverage:report
- name: Upload Coverage Report Artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-report
path: packages/testing/playwright/coverage/
retention-days: 14
- name: Upload E2E Coverage to Codecov
if: always()
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/testing/playwright/coverage/lcov.info
flags: frontend-e2e
name: playwright-e2e
fail_ci_if_error: false

View File

@ -1,14 +0,0 @@
name: Run Playwright Tests (Docker Build)
# This workflow is used to run Playwright tests in a Docker container built from the current branch
on:
workflow_call:
workflow_dispatch:
jobs:
build-and-test:
uses: ./.github/workflows/playwright-test-reusable.yml
with:
test-mode: docker-build
test-command: pnpm --filter=n8n-playwright test:container:standard
secrets: inherit

View File

@ -3,6 +3,10 @@ name: Playwright Tests - Reusable
on:
workflow_call:
inputs:
branch:
description: 'GitHub branch to test.'
required: false
type: string
test-mode:
description: 'Test mode: local (pnpm start from local), docker-build, or docker-pull'
required: false
@ -16,38 +20,55 @@ on:
shards:
description: 'Shards for parallel execution'
required: false
default: '[1, 2, 3, 4, 5, 6]'
default: '[1, 2, 3, 4, 5, 6, 7, 8]'
type: string
docker-image:
description: 'Docker image to use (for docker-pull mode)'
required: false
default: 'n8nio/n8n:nightly'
type: string
workers:
description: 'Number of parallel workers'
required: false
default: ''
type: string
runner:
description: 'GitHub runner to use'
required: false
default: 'blacksmith-2vcpu-ubuntu-2204'
type: string
secrets:
CURRENTS_RECORD_KEY:
required: true
required: false
QA_PERFORMANCE_METRICS_WEBHOOK_URL:
required: true
required: false
QA_PERFORMANCE_METRICS_WEBHOOK_USER:
required: true
required: false
QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD:
required: true
required: false
N8N_LICENSE_ACTIVATION_KEY:
required: false
N8N_ENCRYPTION_KEY:
required: false
env:
PLAYWRIGHT_BROWSERS_PATH: packages/testing/playwright/ms-playwright-cache
NODE_OPTIONS: --max-old-space-size=3072
# Disable Ryuk to avoid issues with Docker since it needs privileged access, containers are cleaned on teardown anyway
TESTCONTAINERS_RYUK_DISABLED: true
PLAYWRIGHT_WORKERS: 3 # We have 2 CPUs on this runner but we can use more workers since it's low CPU intensive
PLAYWRIGHT_WORKERS: ${{ inputs.workers || '2' }} # Configurable workers, defaults to 2 to reduce resource contention
# Must match CI's COVERAGE_ENABLED to ensure Turbo cache hits (it's a globalEnv in turbo.json)
COVERAGE_ENABLED: 'true'
jobs:
test:
runs-on: blacksmith-2vcpu-ubuntu-2204
runs-on: ${{ inputs.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4, 5, 6]') }}
shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4, 5, 6, 7, 8]') }}
name: Test (Shard ${{ matrix.shard }}/${{ strategy.job-total }})
steps:
@ -55,6 +76,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
ref: ${{ inputs.branch || github.ref }}
- name: Setup Environment
uses: ./.github/actions/setup-nodejs-blacksmith
@ -68,6 +90,18 @@ jobs:
if: inputs.test-mode == 'docker-build'
run: pnpm turbo install-browsers:ci
- name: Pre-pull Test Container Images
if: ${{ !contains(inputs.test-command, 'test:local') }}
run: |
# Pre-pull all test container images to avoid network changes during test execution
npx tsx packages/testing/containers/pull-test-images.ts || true
env:
N8N_DOCKER_IMAGE: ${{ inputs.test-mode == 'docker-build' && 'n8nio/n8n:local' || inputs.docker-image }}
- name: Prepare Test Image
if: inputs.test-mode == 'docker-pull'
run: pnpm --filter=n8n-playwright prepare-test-image ${{ inputs.docker-image }}
- name: Run Tests
run: |
${{ inputs.test-command }} \
@ -79,3 +113,4 @@ jobs:
QA_PERFORMANCE_METRICS_WEBHOOK_URL: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_URL }}
QA_PERFORMANCE_METRICS_WEBHOOK_USER: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_USER }}
QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD }}
N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }}

View File

@ -18,6 +18,7 @@ on:
- minor
- major
- experimental
- premajor
jobs:
create-release-pr:
@ -62,6 +63,23 @@ jobs:
run: |
git push -f origin "refs/remotes/origin/${{ env.BASE_BRANCH }}:refs/heads/release/${{ env.NEXT_RELEASE }}"
- name: Generate PR body
id: generate-body
run: |
set -e
CHANGELOG_FILE="CHANGELOG-${{ env.NEXT_RELEASE }}.md"
DELIMITER="EOF_$(uuidgen)"
if [ -f "${CHANGELOG_FILE}" ]; then
{
echo "content<<${DELIMITER}"
cat "${CHANGELOG_FILE}"
echo "${DELIMITER}"
} >> "$GITHUB_OUTPUT"
else
echo "content=No changelog generated. Likely points to fixes in our CI." >> "$GITHUB_OUTPUT"
fi
- name: Push the release branch, and Create the PR
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6
with:
@ -71,4 +89,4 @@ jobs:
delete-branch: true
labels: release,release:${{ github.event.inputs.release-type }}
title: ':rocket: Release ${{ env.NEXT_RELEASE }}'
body-path: 'CHANGELOG-${{ env.NEXT_RELEASE }}.md'
body: ${{ steps.generate-body.outputs.content }}

View File

@ -20,10 +20,13 @@ jobs:
- name: Setup and Build ARM64
uses: ./.github/actions/setup-nodejs-blacksmith
env:
N8N_FAIL_ON_POPULARITY_FETCH_ERROR: true
publish-to-npm:
name: Publish to NPM
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 20
permissions:
id-token: write
@ -31,6 +34,7 @@ jobs:
NPM_CONFIG_PROVENANCE: true
outputs:
release: ${{ steps.set-release.outputs.release }}
release_type: ${{ steps.set-release.outputs.release_type }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -38,8 +42,20 @@ jobs:
- name: Set release version in env
run: echo "RELEASE=$(node -e 'console.log(require("./package.json").version)')" >> "$GITHUB_ENV"
- name: Determine release type
id: release-type
run: |
VERSION="${{ env.RELEASE }}"
if [[ "$VERSION" == *"-rc."* ]]; then
echo "type=rc" >> "$GITHUB_OUTPUT"
else
echo "type=stable" >> "$GITHUB_OUTPUT"
fi
- name: Setup and Build
uses: ./.github/actions/setup-nodejs-github
env:
N8N_FAIL_ON_POPULARITY_FETCH_ERROR: true
- name: Dry-run publishing
run: |
@ -52,7 +68,7 @@ jobs:
node .github/scripts/trim-fe-packageJson.js
node .github/scripts/ensure-provenance-fields.mjs
cp README.md packages/cli/README.md
sed -i "s/default: 'dev'/default: 'stable'/g" packages/cli/dist/config/schema.js
sed -i "s/default: 'dev'/default: '${{ steps.release-type.outputs.type }}'/g" packages/cli/dist/config/schema.js
- name: Publish n8n to NPM with rc tag
env:
@ -69,7 +85,9 @@ jobs:
continue-on-error: true
- id: set-release
run: echo "release=${{ env.RELEASE }}" >> "$GITHUB_OUTPUT"
run: |
echo "release=${{ env.RELEASE }}" >> "$GITHUB_OUTPUT"
echo "release_type=${{ steps.release-type.outputs.type }}" >> "$GITHUB_OUTPUT"
publish-to-docker-hub:
name: Publish to DockerHub
@ -77,7 +95,7 @@ jobs:
uses: ./.github/workflows/docker-build-push.yml
with:
n8n_version: ${{ needs.publish-to-npm.outputs.release }}
release_type: stable
release_type: ${{ needs.publish-to-npm.outputs.release_type }}
secrets: inherit
create-github-release:
@ -153,7 +171,9 @@ jobs:
trigger-release-note:
name: Trigger a release note
needs: [publish-to-npm, create-github-release]
if: github.event.pull_request.merged == true
if: |
github.event.pull_request.merged == true &&
!contains(needs.publish-to-npm.outputs.release, '-rc.')
runs-on: ubuntu-latest
steps:
- name: Trigger a release note

View File

@ -41,6 +41,17 @@ jobs:
exit 1
fi
- name: Block RC promotion to stable/beta
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
CHANNEL: ${{ github.event.inputs.release-channel }}
run: |
if [[ "$INPUT_VERSION" == *"-rc."* ]]; then
echo "::error::RC versions cannot be promoted to '$CHANNEL' channel. Version '$INPUT_VERSION' contains '-rc.'"
exit 1
fi
echo "✅ Version '$INPUT_VERSION' is allowed for '$CHANNEL' channel"
release-to-npm:
name: Release to NPM
runs-on: ubuntu-latest
@ -81,12 +92,16 @@ jobs:
run: |
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/n8n:stable" "${{ secrets.DOCKER_USERNAME }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/n8n:latest" "${{ secrets.DOCKER_USERNAME }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/runners:stable" "${{ secrets.DOCKER_USERNAME }}/runners:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/runners:latest" "${{ secrets.DOCKER_USERNAME }}/runners:${{ needs.validate-inputs.outputs.version }}"
- name: Tag beta/next Docker image
if: needs.validate-inputs.outputs.release_channel == 'beta'
run: |
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/n8n:beta" "${{ secrets.DOCKER_USERNAME }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/n8n:next" "${{ secrets.DOCKER_USERNAME }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/runners:beta" "${{ secrets.DOCKER_USERNAME }}/runners:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "${{ secrets.DOCKER_USERNAME }}/runners:next" "${{ secrets.DOCKER_USERNAME }}/runners:${{ needs.validate-inputs.outputs.version }}"
release-to-github-container-registry:
name: Release to GitHub Container Registry
@ -105,12 +120,16 @@ jobs:
run: |
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/n8n:stable" "ghcr.io/${{ github.repository_owner }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/n8n:latest" "ghcr.io/${{ github.repository_owner }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/runners:stable" "ghcr.io/${{ github.repository_owner }}/runners:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/runners:latest" "ghcr.io/${{ github.repository_owner }}/runners:${{ needs.validate-inputs.outputs.version }}"
- name: Tag beta/next GHCR image
if: needs.validate-inputs.outputs.release_channel == 'beta'
run: |
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/n8n:beta" "ghcr.io/${{ github.repository_owner }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/n8n:next" "ghcr.io/${{ github.repository_owner }}/n8n:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/runners:beta" "ghcr.io/${{ github.repository_owner }}/runners:${{ needs.validate-inputs.outputs.version }}"
docker buildx imagetools create -t "ghcr.io/${{ github.repository_owner }}/runners:next" "ghcr.io/${{ github.repository_owner }}/runners:${{ needs.validate-inputs.outputs.version }}"
update-docs:
name: Update latest and next in the docs

View File

@ -11,6 +11,7 @@ on:
- '@n8n/node-cli'
- '@n8n/create-node'
- '@n8n/scan-community-package'
- '@n8n/eslint-plugin-community-nodes'
concurrency:
group: release-package-${{ github.event.inputs.package }}

View File

@ -38,10 +38,12 @@ jobs:
pnpm build:storybook
- name: Setup Wrangler Pre-requisites
run: echo "ignore-workspace-root-check=true" >> .npmrc
run: |
echo "ignore-workspace-root-check=true" >> .npmrc
pnpm add --global wrangler
- name: Deploy
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
id: cloudflare_deployment
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@ -137,7 +137,7 @@ jobs:
if: steps.verify_file.outputs.file_exists == 'true'
# Pin v7.0.8
uses: peter-evans/create-pull-request@18e469570b1cf0dfc11d60ec121099f8ff3e617a
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -10,7 +10,7 @@ on:
compare_schemas:
description: 'Set to "true" to enable schema comparison during tests.'
required: false
default: 'true'
default: 'false'
type: string
secrets:
ENCRYPTION_KEY:
@ -34,7 +34,7 @@ jobs:
ref: ${{ inputs.git_ref }}
- name: Set up Environment
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
uses: ./.github/actions/setup-nodejs-blacksmith
- name: Set up Workflow Tests
run: pnpm --filter=n8n-playwright test:workflows:setup

View File

@ -1,25 +0,0 @@
name: Test Workflows on PR Approval
on:
pull_request_review:
types: [submitted]
permissions:
contents: read
jobs:
eligibility_check:
name: Check Eligibility for Test Run
if: github.event.review.state == 'approved'
uses: ./.github/workflows/check-run-eligibility.yml
with:
is_pr_approved_by_maintainer: true
run_workflow_tests:
name: Run Tests on Approved Internal PR
needs: [eligibility_check]
if: needs.eligibility_check.outputs.should_run == 'true'
uses: ./.github/workflows/test-workflows-callable.yml
with:
git_ref: ${{ github.event.pull_request.head.sha }}
secrets: inherit

View File

@ -7,7 +7,7 @@ on:
description: GitHub ref to test.
required: false
type: string
default: master
default: ''
nodeVersion:
description: Version of node to use.
required: false
@ -26,9 +26,112 @@ env:
NODE_OPTIONS: --max-old-space-size=7168
jobs:
unit-test:
name: Unit tests
unit-test-backend:
name: Backend Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
with:
node-version: ${{ inputs.nodeVersion }}
- name: Test Unit
run: pnpm test:ci:backend:unit
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
name: backend-unit
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-unit
name: backend-unit
integration-test-backend:
name: Backend Integration Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
with:
node-version: ${{ inputs.nodeVersion }}
- name: Test Integration
run: pnpm test:ci:backend:integration
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
name: backend-integration
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-integration
name: backend-integration
unit-test-nodes:
name: Nodes Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
with:
node-version: ${{ inputs.nodeVersion }}
- name: Test Nodes
run: pnpm turbo test --filter=n8n-nodes-base
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
name: nodes-unit
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: nodes-unit
name: nodes-unit
unit-test-frontend:
name: Frontend (${{ matrix.shard }}/2)
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }}
steps:
@ -42,15 +145,31 @@ jobs:
node-version: ${{ inputs.nodeVersion }}
- name: Test
run: pnpm test:ci
run: pnpm test:ci:frontend -- --shard=${{ matrix.shard }}/2
env:
VITEST_SHARD: ${{ matrix.shard }}/2
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
name: frontend-shard-${{ matrix.shard }}
- name: Upload coverage to Codecov
if: env.COVERAGE_ENABLED == 'true'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: frontend
name: frontend-shard-${{ matrix.shard }}
unit-test:
name: Unit tests
runs-on: ubuntu-latest
needs: [unit-test-backend, integration-test-backend, unit-test-nodes, unit-test-frontend]
if: always()
steps:
- name: Fail if tests failed
if: needs.unit-test-backend.result == 'failure' || needs.integration-test-backend.result == 'failure' || needs.unit-test-nodes.result == 'failure' || needs.unit-test-frontend.result == 'failure'
run: exit 1

View File

@ -0,0 +1,60 @@
name: Update Node Popularity Data
on:
schedule:
# Run every Monday at 00:00 UTC
- cron: '0 0 * * 1'
workflow_dispatch: # Allow manual trigger for testing
permissions:
contents: write
pull-requests: write
jobs:
update-popularity:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js and Dependencies
uses: ./.github/actions/setup-nodejs-github
with:
build-command: '' # Skip build, we only need to fetch data
- name: Fetch node popularity data
run: |
cd packages/frontend/editor-ui
node scripts/fetch-node-popularity.mjs
env:
N8N_FAIL_ON_POPULARITY_FETCH_ERROR: 'false' # Don't fail if API is down
- name: Check for changes
id: check-changes
run: |
if git diff --quiet packages/frontend/editor-ui/data/node-popularity.json; then
echo "No changes to popularity data"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "Popularity data has changed"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
- name: Create Pull Request
if: steps.check-changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
branch-token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: Update node popularity data'
title: 'chore: Update node popularity data'
body: |
This automated PR updates the node popularity data used for sorting nodes in the node creator panel.
The data is fetched weekly from the n8n telemetry endpoint to reflect current usage patterns.
_Generated by the weekly node popularity update workflow._
branch: update-node-popularity
base: master
delete-branch: true
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

1
.gitignore vendored
View File

@ -42,4 +42,5 @@ packages/testing/**/.cursor/rules/
__pycache__
packages/cli/THIRD_PARTY_LICENSES.md
.coverage
.nyc_output
packages/cli/src/commands/export/outputs

View File

@ -8,7 +8,6 @@ packages/cli/templates/form-trigger.handlebars
packages/cli/templates/form-trigger-completion.handlebars
packages/cli/templates/form-trigger-409.handlebars
packages/cli/templates/form-trigger-404.handlebars
cypress/fixtures
CHANGELOG.md
.github/pull_request_template.md
# Ignored for now

6
.vscode/launch.json vendored
View File

@ -26,15 +26,11 @@
"name": "Launch n8n with debug",
"program": "${workspaceFolder}/packages/cli/bin/n8n",
"cwd": "${workspaceFolder}/packages/cli/bin",
// "args": ["start", "--tunnel"],
"request": "launch",
"skipFiles": ["<node_internals>/**"],
"type": "node",
"env": {
// "N8N_PORT": "5679",
},
"outputCapture": "std",
"killBehavior": "polite"
"killBehavior": "forceful"
},
{
"name": "Launch n8n CLI dev with debug",

View File

@ -33,5 +33,6 @@
{
"mode": "auto"
}
]
],
"vitest.workspaceConfig": "./vitest.workspace.ts"
}

157
AGENTS.md Normal file
View File

@ -0,0 +1,157 @@
# AGENTS.md
This file provides guidance on how to work with the n8n repository.
## Project Overview
n8n is a workflow automation platform written in TypeScript, using a monorepo
structure managed by pnpm workspaces. It consists of a Node.js backend, Vue.js
frontend, and extensible node-based workflow engine.
## General Guidelines
- Always use pnpm
- We use Linear as a ticket tracking system
- We use Posthog for feature flags
- When starting to work on a new ticket – create a new branch from fresh
master with the name specified in Linear ticket
- When creating a new branch for a ticket in Linear - use the branch name
suggested by linear
- Use mermaid diagrams in MD files when you need to visualise something
## Essential Commands
### Building
Use `pnpm build` to build all packages. ALWAYS redirect the output of the
build command to a file:
```bash
pnpm build > build.log 2>&1
```
You can inspect the last few lines of the build log file to check for errors:
```bash
tail -n 20 build.log
```
### Testing
- `pnpm test` - Run all tests
- `pnpm test:affected` - Runs tests based on what has changed since the last
commit
Running a particular test file requires going to the directory of that test
and running: `pnpm test <test-file>`.
When changing directories, use `pushd` to navigate into the directory and
`popd` to return to the previous directory. When in doubt, use `pwd` to check
your current directory.
### Code Quality
- `pnpm lint` - Lint code
- `pnpm typecheck` - Run type checks
Always run lint and typecheck before committing code to ensure quality.
Execute these commands from within the specific package directory you're
working on (e.g., `cd packages/cli && pnpm lint`). Run the full repository
check only when preparing the final PR. When your changes affect type
definitions, interfaces in `@n8n/api-types`, or cross-package dependencies,
build the system before running lint and typecheck.
## Architecture Overview
**Monorepo Structure:** pnpm workspaces with Turbo build orchestration
### Package Structure
The monorepo is organized into these key packages:
- **`packages/@n8n/api-types`**: Shared TypeScript interfaces between frontend and backend
- **`packages/workflow`**: Core workflow interfaces and types
- **`packages/core`**: Workflow execution engine
- **`packages/cli`**: Express server, REST API, and CLI commands
- **`packages/editor-ui`**: Vue 3 frontend application
- **`packages/@n8n/i18n`**: Internationalization for UI text
- **`packages/nodes-base`**: Built-in nodes for integrations
- **`packages/@n8n/nodes-langchain`**: AI/LangChain nodes
- **`@n8n/design-system`**: Vue component library for UI consistency
- **`@n8n/config`**: Centralized configuration management
## Technology Stack
- **Frontend:** Vue 3 + TypeScript + Vite + Pinia + Storybook UI Library
- **Backend:** Node.js + TypeScript + Express + TypeORM
- **Testing:** Jest (unit) + Playwright (E2E)
- **Database:** TypeORM with SQLite/PostgreSQL/MySQL support
- **Code Quality:** Biome (for formatting) + ESLint + lefthook git hooks
### Key Architectural Patterns
1. **Dependency Injection**: Uses `@n8n/di` for IoC container
2. **Controller-Service-Repository**: Backend follows MVC-like pattern
3. **Event-Driven**: Internal event bus for decoupled communication
4. **Context-Based Execution**: Different contexts for different node types
5. **State Management**: Frontend uses Pinia stores
6. **Design System**: Reusable components and design tokens are centralized in
`@n8n/design-system`, where all pure Vue components should be placed to
ensure consistency and reusability
## Key Development Patterns
- Each package has isolated build configuration and can be developed independently
- Hot reload works across the full stack during development
- Node development uses dedicated `node-dev` CLI tool
- Workflow tests are JSON-based for integration testing
- AI features have dedicated development workflow (`pnpm dev:ai`)
### TypeScript Best Practices
- **NEVER use `any` type** - use proper types or `unknown`
- **Avoid type casting with `as`** - use type guards or type predicates instead
- **Define shared interfaces in `@n8n/api-types`** package for FE/BE communication
### Error Handling
- Don't use `ApplicationError` class in CLI and nodes for throwing errors,
because it's deprecated. Use `UnexpectedError`, `OperationalError` or
`UserError` instead.
- Import from appropriate error classes in each package
### Frontend Development
- **All UI text must use i18n** - add translations to `@n8n/i18n` package
- **Use CSS variables directly** - never hardcode spacing as px values
- **data-test-id must be a single value** (no spaces or multiple values)
When implementing CSS, refer to @packages/frontend/CLAUDE.md for guidelines on
CSS variables and styling conventions.
### Testing Guidelines
- **Always work from within the package directory** when running tests
- **Mock all external dependencies** in unit tests
- **Confirm test cases with user** before writing unit tests
- **Typecheck is critical before committing** - always run `pnpm typecheck`
- **When modifying pinia stores**, check for unused computed properties
What we use for testing and writing tests:
- For testing nodes and other backend components, we use Jest for unit tests. Examples can be found in `packages/nodes-base/nodes/**/*test*`.
- We use `nock` for server mocking
- For frontend we use `vitest`
- For E2E tests we use Playwright. Run with `pnpm --filter=n8n-playwright test:local`.
See `packages/testing/playwright/README.md` for details.
### Common Development Tasks
When implementing features:
1. Define API types in `packages/@n8n/api-types`
2. Implement backend logic in `packages/cli` module, follow
`@packages/cli/scripts/backend-module/backend-module-guide.md`
3. Add API endpoints via controllers
4. Update frontend in `packages/editor-ui` with i18n support
5. Write tests with proper mocks
6. Run `pnpm typecheck` to verify types
## Github Guidelines
- When creating a PR, use the conventions in
`.github/pull_request_template.md` and
`.github/pull_request_title_conventions.md`.
- Use `gh pr create --draft` to create draft PRs.
- Always reference the Linear ticket in the PR description,
use `https://linear.app/n8n/issue/[TICKET-ID]`
- always link to the github issue if mentioned in the linear ticket.

View File

@ -1,3 +1,710 @@
# [2.0.0](https://github.com/n8n-io/n8n/compare/n8n@2.0.0-rc.3...n8n@2.0.0) (2025-12-08)
### Bug Fixes
* Add `HOME` env var to distroless runners image ([#22796](https://github.com/n8n-io/n8n/issues/22796)) ([2cf1021](https://github.com/n8n-io/n8n/commit/2cf10216bd396118eb170f237b918df5850a3402))
* **core:** Allowlist `HOME` env var in JS runner config ([#22839](https://github.com/n8n-io/n8n/issues/22839)) ([a31ca3d](https://github.com/n8n-io/n8n/commit/a31ca3d19052f7fdecb1c2ed9a81e15ff9bd14b6))
* **core:** Do not prevent credential save if property has default value ([#22720](https://github.com/n8n-io/n8n/issues/22720)) ([03744c3](https://github.com/n8n-io/n8n/commit/03744c30c59b96a8ad67d8d6b321e0f7ff0571e2))
* **core:** Update migration that activate workflows with executeWorkflowTrigger ([#22860](https://github.com/n8n-io/n8n/issues/22860)) ([f7cb18c](https://github.com/n8n-io/n8n/commit/f7cb18ce3853a1813646460fc472b0d20f482a53))
* **editor:** Bind color-scheme to app theme setting ([#22774](https://github.com/n8n-io/n8n/issues/22774)) ([3d46b97](https://github.com/n8n-io/n8n/commit/3d46b97bdf1c01852998ce5a5eb4631fe990d268))
* **editor:** Design tweaks for publish modals ([#22694](https://github.com/n8n-io/n8n/issues/22694)) ([98efa19](https://github.com/n8n-io/n8n/commit/98efa19ca4d9cf17c88a3778c00fea45caa54738))
* Fetch workflow before updating checksum ([#22927](https://github.com/n8n-io/n8n/issues/22927)) ([2260953](https://github.com/n8n-io/n8n/commit/22609531c39dce6477123734f68a53d932a26d8b))
* Security patches ([#22748](https://github.com/n8n-io/n8n/issues/22748)) ([d22c173](https://github.com/n8n-io/n8n/commit/d22c173aec9f990f1912450399eea29893e62d24))
* Security patches for jws ([#22918](https://github.com/n8n-io/n8n/issues/22918)) ([a642722](https://github.com/n8n-io/n8n/commit/a642722115a011f16292074833d47bee540a34b2))
* Update checksum after restoring workflow ([#22920](https://github.com/n8n-io/n8n/issues/22920)) ([207c3f8](https://github.com/n8n-io/n8n/commit/207c3f8ba81e9c5b2148278f7def492b4789d2dc))
### Features
* Validate nodes before activating ([#22916](https://github.com/n8n-io/n8n/issues/22916)) ([1aa325a](https://github.com/n8n-io/n8n/commit/1aa325a64f26a8b377cee341df7486dcedd535b2))
# [2.0.0-rc.3](https://github.com/n8n-io/n8n/compare/n8n@2.0.0-rc.2...n8n@2.0.0-rc.3) (2025-12-04)
### Bug Fixes
* **core:** Address review feedback for sub-workflow wait fix ([#22745](https://github.com/n8n-io/n8n/issues/22745)) ([ce64032](https://github.com/n8n-io/n8n/commit/ce6403262badd93ccde1a254f71631adc2176886))
### Features
* **editor:** Improve node component visual states ([#20242](https://github.com/n8n-io/n8n/issues/20242)) ([3fc1c56](https://github.com/n8n-io/n8n/commit/3fc1c566a468509c758bd0800b30f3a47d981b4c))
# [2.0.0-rc.2](https://github.com/n8n-io/n8n/compare/n8n@2.0.0-rc.1...n8n@2.0.0-rc.2) (2025-12-04)
### Bug Fixes
* **core:** Prevent execution data from being overwritten on manual workflow resume ([#22665](https://github.com/n8n-io/n8n/issues/22665)) ([f603bc8](https://github.com/n8n-io/n8n/commit/f603bc891f68d3779b4a8efaf667311afe38f2c9))
# [2.0.0-rc.1](https://github.com/n8n-io/n8n/compare/n8n@2.0.0-rc.0...n8n@2.0.0-rc.1) (2025-12-04)
### Bug Fixes
* **core:** Make sub-workflows with waits return correct data to parents ([#22611](https://github.com/n8n-io/n8n/issues/22611)) ([14205dc](https://github.com/n8n-io/n8n/commit/14205dc0c6ae15b1329e46584c6b472100fe0ad5))
### Features
* **core:** Block access to env in code and expressions by default ([#22643](https://github.com/n8n-io/n8n/issues/22643)) ([0ffd760](https://github.com/n8n-io/n8n/commit/0ffd76093c2e9b62098e14dc7e402f727e14e027))
* **core:** Introduce native Python code tool for AI agent ([#22658](https://github.com/n8n-io/n8n/issues/22658)) ([b1bc778](https://github.com/n8n-io/n8n/commit/b1bc7786d860d0b9e02fbda3604a979ed8d39f3d))
* **editor:** Sidebar redesign ([#22395](https://github.com/n8n-io/n8n/issues/22395)) ([0ba2043](https://github.com/n8n-io/n8n/commit/0ba2043047dadfdccf96b30f5653886430d7eb54))
# [2.0.0-rc.0](https://github.com/n8n-io/n8n/compare/n8n@1.122.0...n8n@2.0.0-rc.0) (2025-12-02)
### Bug Fixes
### Features
# [1.123.0](https://github.com/n8n-io/n8n/compare/n8n@1.122.0...n8n@1.123.0) (2025-12-01)
### Bug Fixes
* **AI Agent Node:** Unify memory management for streaming/non-streaming ([#22243](https://github.com/n8n-io/n8n/issues/22243)) ([df81c77](https://github.com/n8n-io/n8n/commit/df81c77a248136b141e2324036cc8ecdc41f3590))
* **ai-builder:** Fix import of multiple nodes with maxNode, add validation ([#22348](https://github.com/n8n-io/n8n/issues/22348)) ([4319da6](https://github.com/n8n-io/n8n/commit/4319da6f1cc1421285ada04551969a170ca5c518))
* **ai-builder:** Keep existing pin data when modifying the workflow ([#22266](https://github.com/n8n-io/n8n/issues/22266)) ([83ea8e1](https://github.com/n8n-io/n8n/commit/83ea8e1f91c5ee07fee89e39743b3c39bbd479db))
* Bump node-forge and body-parser ([#22418](https://github.com/n8n-io/n8n/issues/22418)) ([3c0e809](https://github.com/n8n-io/n8n/commit/3c0e809e416569c64d3c5e96c4c5f5f84b15e255))
* **core, editor:** Support inclusive and exclusive destination node mode in the frontend and API ([#22244](https://github.com/n8n-io/n8n/issues/22244)) ([344c909](https://github.com/n8n-io/n8n/commit/344c90940e7e75ddaeba5e23c0953feebb7aa98e))
* **core:** Add missing workflowId when creating base subworkflowworkflow additional data ([#22312](https://github.com/n8n-io/n8n/issues/22312)) ([8f6c3b2](https://github.com/n8n-io/n8n/commit/8f6c3b2dce25fde6c64a04b85179d829d1d45feb))
* **core:** Add response validation for requests during DCR ([#22076](https://github.com/n8n-io/n8n/issues/22076)) ([9ab9d1c](https://github.com/n8n-io/n8n/commit/9ab9d1c8f1fd4e65e9a7924f5d7bc3925b024261))
* **core:** Defer requirements check for Python runner in internal mode ([#22448](https://github.com/n8n-io/n8n/issues/22448)) ([97d8b39](https://github.com/n8n-io/n8n/commit/97d8b393261eb8306d8cc33bb4883fc7be3ee1b2))
* **core:** Introduce batch workflow rule to fix subworkflow with wait node detection ([#22447](https://github.com/n8n-io/n8n/issues/22447)) ([e0bc441](https://github.com/n8n-io/n8n/commit/e0bc4416ea1709cf5468f580074d46cd43cff09d))
* **core:** Mark `''` and `[]` as empty when filtering numbers ([#22347](https://github.com/n8n-io/n8n/issues/22347)) ([a4d2cfa](https://github.com/n8n-io/n8n/commit/a4d2cfae4af2a2080b0e610ea3298e24238871ae))
* **core:** OIDC form breaks when enabling OIDC while SAML is active ([#22463](https://github.com/n8n-io/n8n/issues/22463)) ([613b088](https://github.com/n8n-io/n8n/commit/613b08843d6542de2e3e98ee2a0535535294ce1b))
* **core:** Prevent credentials saving if missing required properties ([#22213](https://github.com/n8n-io/n8n/issues/22213)) ([6d88007](https://github.com/n8n-io/n8n/commit/6d88007f014b1dba779f9217d5f3cf5fab854c3e))
* **core:** Send prod workflow succeeded for wfs in projects ([#22223](https://github.com/n8n-io/n8n/issues/22223)) ([7068fe2](https://github.com/n8n-io/n8n/commit/7068fe2510882c438cbd73bede40dbbb1a1eebbb))
* Detect workflow calling itself expression in migration report ([#22516](https://github.com/n8n-io/n8n/issues/22516)) ([1ddb634](https://github.com/n8n-io/n8n/commit/1ddb6344a5dbddd6385f1d68d94314fa5ab72cb1))
* **editor, core:** Fix display for binary data metadata origin ([#22270](https://github.com/n8n-io/n8n/issues/22270)) ([df1aa13](https://github.com/n8n-io/n8n/commit/df1aa130cb3a417b5795b13af50e8c57f5137aff))
* **editor:** Avoid clearing EULA license key when closing dialog ([#22424](https://github.com/n8n-io/n8n/issues/22424)) ([fe05ea4](https://github.com/n8n-io/n8n/commit/fe05ea4df8b415f600bb8d29bf29544683f70c3a))
* **editor:** Eval trigger node with data table works when underlying data changes ([#22389](https://github.com/n8n-io/n8n/issues/22389)) ([6f1b09e](https://github.com/n8n-io/n8n/commit/6f1b09eeda8554abc207e0640fee8cc500cea3c6))
* **editor:** Fix editor pan / zoom when inserting nodes ([#22401](https://github.com/n8n-io/n8n/issues/22401)) ([f435430](https://github.com/n8n-io/n8n/commit/f4354301ecb0cbb0d6386d4f32c2641845cfc8fe))
* **editor:** Fix issue for blinking/jumping UI when switching projects ([#22256](https://github.com/n8n-io/n8n/issues/22256)) ([6185550](https://github.com/n8n-io/n8n/commit/6185550971e133beb9092d3695eecfa6e66e6113))
* **editor:** Fix sub-nodes connection labels counters ([#21549](https://github.com/n8n-io/n8n/issues/21549)) ([d366cb4](https://github.com/n8n-io/n8n/commit/d366cb4f37eacc422cafc7795180837a3f1087e8))
* **editor:** Missing duplicate workflow action on workflow list ([#22230](https://github.com/n8n-io/n8n/issues/22230)) ([000cccb](https://github.com/n8n-io/n8n/commit/000cccb62700144fd41ed70e2177de1c3cb32c31))
* **editor:** Preserve paired item data in pinned data ([#21417](https://github.com/n8n-io/n8n/issues/21417)) ([254a53e](https://github.com/n8n-io/n8n/commit/254a53e7bb8acdd3202841b0ec85a87907e4379b))
* **editor:** Remove unwanted outlines when waiting for webhook event in light mode in new NDV ([#22425](https://github.com/n8n-io/n8n/issues/22425)) ([4d68b6c](https://github.com/n8n-io/n8n/commit/4d68b6c6987d7193fd35aa1c41958252df56ab02))
* **editor:** Standardize CSS class naming conventions across new design system components ([#22551](https://github.com/n8n-io/n8n/issues/22551)) ([c82d95a](https://github.com/n8n-io/n8n/commit/c82d95aecbbdf3379929ca99fb474909f08bfbf4))
* **editor:** Wording for migration rule detail table headers ([#22398](https://github.com/n8n-io/n8n/issues/22398)) ([cd9f565](https://github.com/n8n-io/n8n/commit/cd9f56569dcf1e65df2d4f72b7007514276dac01))
* Enable streaming version of Respond To Webhook node ([#22350](https://github.com/n8n-io/n8n/issues/22350)) ([dc407c2](https://github.com/n8n-io/n8n/commit/dc407c25fd3e9cc60704ae53c9508ee738c24efb))
* **Git Node:** Throw an error if the repository path is blocked ([#22253](https://github.com/n8n-io/n8n/issues/22253)) ([a49b179](https://github.com/n8n-io/n8n/commit/a49b179e891c0b3501b6784c82af72b5a8c5a02d))
* **Gmail Node:** Use Reply-To header when replying to a message ([#22145](https://github.com/n8n-io/n8n/issues/22145)) ([2a3cba7](https://github.com/n8n-io/n8n/commit/2a3cba74ee8716cc94c96b8849bda02bc88dbbcb))
* **HTTP Node:** Handle Azure Storage Shared Key ([#22136](https://github.com/n8n-io/n8n/issues/22136)) ([b581dbc](https://github.com/n8n-io/n8n/commit/b581dbc23262c4f96f262e23d9274765cb54e09c))
* **HTTP Request Node:** Detect and handle non-UTF-8 response encodings ([#20889](https://github.com/n8n-io/n8n/issues/20889)) ([6068fb3](https://github.com/n8n-io/n8n/commit/6068fb3b2008ed6e4cbbd01057bca280c91f021b))
* Improve insights tests execution failure visibility ([#22538](https://github.com/n8n-io/n8n/issues/22538)) ([f937c0f](https://github.com/n8n-io/n8n/commit/f937c0fb5089dedab01361f67e39e7280d964d8a))
* **MCP Client Node:** Fix selecting PKCE auth flow for some servers and request scopes from `scopes_requested` during DCR ([#22405](https://github.com/n8n-io/n8n/issues/22405)) ([6765d15](https://github.com/n8n-io/n8n/commit/6765d15ead1e0226078d4a6adbbf7d36e7a827fb))
* Pin n8n packages to workspace version ([#22460](https://github.com/n8n-io/n8n/issues/22460)) ([75b2cd0](https://github.com/n8n-io/n8n/commit/75b2cd0de6b02b392be2b1e4f61a243da7bfe5b8))
* **PostgreSQL Node:** Input items with array being modified ([#22426](https://github.com/n8n-io/n8n/issues/22426)) ([42cda59](https://github.com/n8n-io/n8n/commit/42cda59ee5f175b2b9f887b8a5fbdd60fda8184b))
* Renovate will now bump versions ([#22245](https://github.com/n8n-io/n8n/issues/22245)) ([17ea0dd](https://github.com/n8n-io/n8n/commit/17ea0dd4662beecc61ec7fbf4bb45abeab69de54))
* Show correct date range in insight overview ([3130d20](https://github.com/n8n-io/n8n/commit/3130d205331124d08299c54db5a31a0a6781e5b3))
* **Slack Node:** Sort messages manually ([#21822](https://github.com/n8n-io/n8n/issues/21822)) ([52b93ed](https://github.com/n8n-io/n8n/commit/52b93ed5b237e031c4e079ad3e620c0943fb8cda))
* **Sort Node:** Periodic error when using Code sort type ([#22409](https://github.com/n8n-io/n8n/issues/22409)) ([2360d87](https://github.com/n8n-io/n8n/commit/2360d8719a529b61989da9b215a6c0a7da861087))
* Support resolving $fromAI in vector store tools ([#22457](https://github.com/n8n-io/n8n/issues/22457)) ([d72d68c](https://github.com/n8n-io/n8n/commit/d72d68c7191576501e8b1a387a0d8c1366c83c05))
* Update base image dependencies to latest security patches ([#22275](https://github.com/n8n-io/n8n/issues/22275)) ([5fd1702](https://github.com/n8n-io/n8n/commit/5fd1702429eb67bd13d5e0978faf93015c1d527f))
* When chat session is reset, update session id ([#22288](https://github.com/n8n-io/n8n/issues/22288)) ([7d82c3c](https://github.com/n8n-io/n8n/commit/7d82c3c02a4f61941e714de76c450d3e9c21ad98))
### Features
* Add entries to context menu to copy webhook's urls ([#21769](https://github.com/n8n-io/n8n/issues/21769)) ([6e344f0](https://github.com/n8n-io/n8n/commit/6e344f0f2968a8367358761ca7606787c375cbcc))
* Add the time saved node ([#22269](https://github.com/n8n-io/n8n/issues/22269)) ([b83c43f](https://github.com/n8n-io/n8n/commit/b83c43f8dd9d16932a3c45da908ee30053624f62))
* Add time saved mode workflow setting ([#22343](https://github.com/n8n-io/n8n/issues/22343)) ([b2f78d7](https://github.com/n8n-io/n8n/commit/b2f78d7c7aba592a70c08751f2ef0a004a0be60f))
* **ai-builder:** Using templates to improve generation ([#22521](https://github.com/n8n-io/n8n/issues/22521)) ([7186dcf](https://github.com/n8n-io/n8n/commit/7186dcfe7ee184319aefce4ae2c09ead6dece418))
* Allow configuring workflow for time saved capture by node ([#22386](https://github.com/n8n-io/n8n/issues/22386)) ([4adfced](https://github.com/n8n-io/n8n/commit/4adfced9373ca2b4da57ec9a28cbae63c6e615f8))
* **Azure AI Search Node:** Add clear index option to Azure AI Search vector store ([#22183](https://github.com/n8n-io/n8n/issues/22183)) ([5cb594d](https://github.com/n8n-io/n8n/commit/5cb594d7efcc52c834855d13850735939e7d7086))
* **core:** Add get version public endpoint ([#22407](https://github.com/n8n-io/n8n/issues/22407)) ([b6f8050](https://github.com/n8n-io/n8n/commit/b6f8050dfab80647fa2ed336c23566654918f936))
* **core:** Deactivate crashed workflows ([#21888](https://github.com/n8n-io/n8n/issues/21888)) ([710a654](https://github.com/n8n-io/n8n/commit/710a6548d444fe740158333ce362fec62c8d3075))
* **core:** Inject hooks into applicable trigger node properties for the node UI ([#22290](https://github.com/n8n-io/n8n/issues/22290)) ([92dca5f](https://github.com/n8n-io/n8n/commit/92dca5f739c535d8145fd54be8235e441e2c08ba))
* **core:** Introduce `database` mode for binary data storage ([#22162](https://github.com/n8n-io/n8n/issues/22162)) ([934b9a7](https://github.com/n8n-io/n8n/commit/934b9a7346c713c4f3ced9fb7b972eb231affc15))
* **core:** Use new workflow history in mcp tools ([#22384](https://github.com/n8n-io/n8n/issues/22384)) ([32c2909](https://github.com/n8n-io/n8n/commit/32c2909d729375408db13696357895a5ec28d28a))
* **Form Node:** Allow users to set a default value for form fields ([#22200](https://github.com/n8n-io/n8n/issues/22200)) ([b0fc88b](https://github.com/n8n-io/n8n/commit/b0fc88b437e376b8283ed6bddcca63bac74c1aae))
* **Gemini Node:** Add support for Nano Banana Pro model ([#22254](https://github.com/n8n-io/n8n/issues/22254)) ([7a4cc40](https://github.com/n8n-io/n8n/commit/7a4cc40dc85998790d57e336ab3384905912222c))
* **Guardrails Node:** Require Chat model only for LLM checks ([#22241](https://github.com/n8n-io/n8n/issues/22241)) ([c1dade7](https://github.com/n8n-io/n8n/commit/c1dade7ad397d0aed2245554d32906f774860a3f))
* Reduce unauthentication information in settings endpoint further ([#22106](https://github.com/n8n-io/n8n/issues/22106)) ([83809e6](https://github.com/n8n-io/n8n/commit/83809e62ad0f3d22447549b03173f38f93f523a1))
# [1.122.0](https://github.com/n8n-io/n8n/compare/n8n@1.121.0...n8n@1.122.0) (2025-11-24)
### Bug Fixes
* @n8n/scan-community-package not working on Windows ([#22077](https://github.com/n8n-io/n8n/issues/22077)) ([27eeec0](https://github.com/n8n-io/n8n/commit/27eeec01574e939de8c9eb1f349109f9127e7bf2))
* **Agent Node:** Thinking model issue - undefined `.map` error ([#22046](https://github.com/n8n-io/n8n/issues/22046)) ([231e4ef](https://github.com/n8n-io/n8n/commit/231e4eff57efe2a9ff11f4e5bcbd36c252e8006f))
* **ai-builder:** Fixing scroll issues when workflow builder when NDV is open ([#22005](https://github.com/n8n-io/n8n/issues/22005)) ([4d525e8](https://github.com/n8n-io/n8n/commit/4d525e83b1e2a379bf4fc0d3e09031a27c7250e2))
* **ai-builder:** Improving workflow builder following model instructions and using AI agent node ([#22011](https://github.com/n8n-io/n8n/issues/22011)) ([da2446e](https://github.com/n8n-io/n8n/commit/da2446ead3239cbbb95c5552637aee157831e8c6))
* **API:** Add correct payload example ([#22057](https://github.com/n8n-io/n8n/issues/22057)) ([a0c2071](https://github.com/n8n-io/n8n/commit/a0c2071cb0ba324bd2d42225f4e96b4a3a0b1361))
* **ChatTrigger Node:** Keep session ID when load previous session is on ([#22150](https://github.com/n8n-io/n8n/issues/22150)) ([ca84de8](https://github.com/n8n-io/n8n/commit/ca84de8d56e29cdbec89c82380636de549221d25))
* **core:** Add project id on /new and /from-url endpoints to add project scope auth ([#21865](https://github.com/n8n-io/n8n/issues/21865)) ([cb0fa96](https://github.com/n8n-io/n8n/commit/cb0fa963cac3c5d11d6fca46b2bc1223333ae73e))
* **core:** Allow chat to process text files as well as images ([#22093](https://github.com/n8n-io/n8n/issues/22093)) ([aa17707](https://github.com/n8n-io/n8n/commit/aa17707805617c411a912ae3ddb49fbdb1a67087))
* **core:** Fix mcp access scope issue ([#22031](https://github.com/n8n-io/n8n/issues/22031)) ([eb6cbfc](https://github.com/n8n-io/n8n/commit/eb6cbfc5e3f4e7adf9faf7afcead016333ebd908))
* **core:** Prevent worker instance from running insights collection ([#21610](https://github.com/n8n-io/n8n/issues/21610)) ([219b60b](https://github.com/n8n-io/n8n/commit/219b60b8d14f59496326902b2a3b60c1ce1ff3b0))
* **core:** Restore `moment` to regular runners image ([#22212](https://github.com/n8n-io/n8n/issues/22212)) ([b3ceeaf](https://github.com/n8n-io/n8n/commit/b3ceeafb967ceb6d96d0511ac53cb9660ca595d0))
* **core:** Use execFile instead of exec to prevent command injection ([#21952](https://github.com/n8n-io/n8n/issues/21952)) ([296684e](https://github.com/n8n-io/n8n/commit/296684e67c10e40d1f70dcdb1ebc07d2d9b7e18a))
* **editor:** Add responsiveness to the templates panel ([#22132](https://github.com/n8n-io/n8n/issues/22132)) ([1527fe9](https://github.com/n8n-io/n8n/commit/1527fe916baa7e552632df35b698496a154e9f1c))
* **editor:** Close card dropdowns when workflows list is scrolled ([#21630](https://github.com/n8n-io/n8n/issues/21630)) ([c6434ac](https://github.com/n8n-io/n8n/commit/c6434aca0396e10e6f49bf999c4e93168491901d))
* **editor:** Fixing tag style on canvas ([#21957](https://github.com/n8n-io/n8n/issues/21957)) ([2b03b5b](https://github.com/n8n-io/n8n/commit/2b03b5bcf29a8f84cc48574ff9760ed3209c7d3f))
* **editor:** Inconsistent syntax highlight color ([#22029](https://github.com/n8n-io/n8n/issues/22029)) ([5d1c375](https://github.com/n8n-io/n8n/commit/5d1c375fc662b46960244a67149e89f5355052dd))
* **editor:** Minor design fixes for the migration report ([#22110](https://github.com/n8n-io/n8n/issues/22110)) ([68a81c2](https://github.com/n8n-io/n8n/commit/68a81c2ed6c64550c890d8efc99dc055092ec672))
* **editor:** Notice background colors ([#22044](https://github.com/n8n-io/n8n/issues/22044)) ([ad1e422](https://github.com/n8n-io/n8n/commit/ad1e422babe1f1d1776167546e6c8eff44b4f958))
* **editor:** Reduce length of the component and update position ([#21838](https://github.com/n8n-io/n8n/issues/21838)) ([29527d0](https://github.com/n8n-io/n8n/commit/29527d0579b01fdfee3fa5d49e89b2615208aafb))
* **editor:** Replace icon for null in schema view ([#21415](https://github.com/n8n-io/n8n/issues/21415)) ([e569750](https://github.com/n8n-io/n8n/commit/e569750e328d62fd7a3fbd41f9fcb7c7320da23b))
* **editor:** Trim whitespace from workflow owner name ([#21483](https://github.com/n8n-io/n8n/issues/21483)) ([0dca2b0](https://github.com/n8n-io/n8n/commit/0dca2b0f129e714bb4fb0da43dac8a2438f46d45))
* **editor:** Typo fix for oauth ([#21980](https://github.com/n8n-io/n8n/issues/21980)) ([2681c70](https://github.com/n8n-io/n8n/commit/2681c7033eb4657f30ae9fcc8bf745c42a0a00d4))
* Fixing failing CI tests for `nodes-langchain` package ([#22099](https://github.com/n8n-io/n8n/issues/22099)) ([77b7c2d](https://github.com/n8n-io/n8n/commit/77b7c2dd1a432a684b6f7af432daeb41a7aed5a6))
* **Google BigQuery Node:** Prevent infinite loop on job fail ([#21741](https://github.com/n8n-io/n8n/issues/21741)) ([008cd8d](https://github.com/n8n-io/n8n/commit/008cd8d08369ad188625fb5efaa6fa3707c1548e))
* Improve json repair logic ([#22088](https://github.com/n8n-io/n8n/issues/22088)) ([60a9cbf](https://github.com/n8n-io/n8n/commit/60a9cbf13368be084c7ceb19ae2c28dd538bcc5a))
* Improve logging for settings store ([#22216](https://github.com/n8n-io/n8n/issues/22216)) ([e3a2614](https://github.com/n8n-io/n8n/commit/e3a2614792f90078a5957f92016b115133fd4e99))
* In queue mode "Respond to Webhook" node returns 500 when there is an error in the execution ([#21981](https://github.com/n8n-io/n8n/issues/21981)) ([fe297d0](https://github.com/n8n-io/n8n/commit/fe297d09aba112779087b6f344747c78fe42e626))
* **n8n Form Node:** Allow undefined formatDate ([#21811](https://github.com/n8n-io/n8n/issues/21811)) ([a9f117c](https://github.com/n8n-io/n8n/commit/a9f117cefe9d49753191f35a1c59dcb38924a25e))
* **OpenAi Node:** Fix grammar in audio operation descriptions ([#22041](https://github.com/n8n-io/n8n/issues/22041)) ([75402fb](https://github.com/n8n-io/n8n/commit/75402fb68115088cf9ce5623ad36445ab7eabb04))
* **OpenAI Node:** Use new model for text classification ([#22130](https://github.com/n8n-io/n8n/issues/22130)) ([7c4771e](https://github.com/n8n-io/n8n/commit/7c4771e62379cbe49f0fdf909f0d268a2d332b37))
* **Salesforce Trigger Node:** Fix Opportunity updated description ([#22169](https://github.com/n8n-io/n8n/issues/22169)) ([07fcd07](https://github.com/n8n-io/n8n/commit/07fcd0708c40f0b6562efedc168649d716277ed8))
### Features
* Add best practices for all builder categories ([#21943](https://github.com/n8n-io/n8n/issues/21943)) ([214a7e9](https://github.com/n8n-io/n8n/commit/214a7e9af3c0c74772526ff24c2fdce9da50058c))
* Add support for global credentials ([#21700](https://github.com/n8n-io/n8n/issues/21700)) ([55c3150](https://github.com/n8n-io/n8n/commit/55c3150c1126c277a3bfff5ff2fce07b4a667969))
* **ai-builder:** Add python script for workflow comparison ([#21927](https://github.com/n8n-io/n8n/issues/21927)) ([246387e](https://github.com/n8n-io/n8n/commit/246387e3c985fe8de9078749be587b91bd9c898d))
* **ai-builder:** Send workflow validation issues to telemetry ([#21837](https://github.com/n8n-io/n8n/issues/21837)) ([0a355cc](https://github.com/n8n-io/n8n/commit/0a355ccadbd49f824611a8b5dbda43afffdeb83d))
* Block UI updates for instance / project roles if provisioning enabed ([#22095](https://github.com/n8n-io/n8n/issues/22095)) ([72cdca2](https://github.com/n8n-io/n8n/commit/72cdca23d6b58352618a7386799ce43a260f320c))
* **core:** Allow creating data tables from csv files ([#21051](https://github.com/n8n-io/n8n/issues/21051)) ([2830665](https://github.com/n8n-io/n8n/commit/2830665f7a325b3e4d8cf12b9f51eca76961268b))
* **core:** Configurable workflow statistics metrics ([#18241](https://github.com/n8n-io/n8n/issues/18241)) ([a896417](https://github.com/n8n-io/n8n/commit/a896417300f8d30dc1e1c995049b14a73150b6d6))
* **core:** Make user role provisioning available on enterprise ([#22166](https://github.com/n8n-io/n8n/issues/22166)) ([6786915](https://github.com/n8n-io/n8n/commit/6786915537e43e4bbc1b525e40d41bb7020f3d45))
* **core:** Move settings for SSO user role provisioning from dedicated page to existing form ([#21901](https://github.com/n8n-io/n8n/issues/21901)) ([34039b3](https://github.com/n8n-io/n8n/commit/34039b370b4d29831f3e8d25a6b89ac3dcc963ff))
* **core:** Shorten copy text on confirm provisioning dialog ([#22086](https://github.com/n8n-io/n8n/issues/22086)) ([4ddd708](https://github.com/n8n-io/n8n/commit/4ddd7089b3632db6fe326f956dc27fc74918f59d))
* **core:** Update binary data breaking change ([#22100](https://github.com/n8n-io/n8n/issues/22100)) ([4c2c1ce](https://github.com/n8n-io/n8n/commit/4c2c1ce9d1d59947a33da3334a7ce051ecbbdfeb))
* **editor:** Add dismissable callout for scaling mode enabled customers ([#21897](https://github.com/n8n-io/n8n/issues/21897)) ([fe5697d](https://github.com/n8n-io/n8n/commit/fe5697dee110a1867bfef8036f6ccee04d024455))
* **editor:** CSV download support for data tables ([#22048](https://github.com/n8n-io/n8n/issues/22048)) ([81a3d39](https://github.com/n8n-io/n8n/commit/81a3d395f28c3f70547fd379ed1ecf8ab083fffc))
* **editor:** Custom project roles Beta release ([#22228](https://github.com/n8n-io/n8n/issues/22228)) ([1a7089d](https://github.com/n8n-io/n8n/commit/1a7089df7a562eac3c4f1b530015cad6caf6aea2))
* **editor:** Improve Data tables visibility ([#22074](https://github.com/n8n-io/n8n/issues/22074)) ([993040a](https://github.com/n8n-io/n8n/commit/993040a2c576d72f5661ffd1e7108889c99c37f0))
* **editor:** Update command bar wf search to match by any query term ([#21798](https://github.com/n8n-io/n8n/issues/21798)) ([5591207](https://github.com/n8n-io/n8n/commit/55912079d16d3478083f52fe7c3a8deb4f47066c))
* **MCP Client Node:** New node ([#21942](https://github.com/n8n-io/n8n/issues/21942)) ([69e092a](https://github.com/n8n-io/n8n/commit/69e092a263fc5890d783247eb2759d1419f9c7f1))
* **Stripe Node:** Add meter events for billing ([#21962](https://github.com/n8n-io/n8n/issues/21962)) ([5319bc8](https://github.com/n8n-io/n8n/commit/5319bc8659a1b4c3fa35abb0c7248e7548e97dea))
# [1.121.0](https://github.com/n8n-io/n8n/compare/n8n@1.120.0...n8n@1.121.0) (2025-11-18)
### Bug Fixes
* **Code Node:** Revert node icon back to old icon ([#21777](https://github.com/n8n-io/n8n/issues/21777)) ([7bb3fdc](https://github.com/n8n-io/n8n/commit/7bb3fdc270291e5fe08f673c2e278bf95f199003))
* **core:** Add timeout and recovery for database connection health checks ([#21506](https://github.com/n8n-io/n8n/issues/21506)) ([09c8b2d](https://github.com/n8n-io/n8n/commit/09c8b2dea89295dad66bec7c33f422c8e0a086ed))
* **core:** Capture stoppedAt timestamp and improve fullRunData handling ([#21290](https://github.com/n8n-io/n8n/issues/21290)) ([343413d](https://github.com/n8n-io/n8n/commit/343413dbd9b35a1602a17ef8c4c432c0fe534b71))
* **core:** Correctly filter custom nodes when NODES_INCLUDE/EXCLUDE is set ([#21705](https://github.com/n8n-io/n8n/issues/21705)) ([08fea7b](https://github.com/n8n-io/n8n/commit/08fea7b17fbc7532b56279ad917c495bff7a327a))
* **core:** Declarative Node - Continue using Error Output returns empty item ([#21823](https://github.com/n8n-io/n8n/issues/21823)) ([e3a996d](https://github.com/n8n-io/n8n/commit/e3a996d7d2f1ab8a686b5d1fa5a960c16f1beeeb))
* **core:** Enforce timeout for task requests ([#21493](https://github.com/n8n-io/n8n/issues/21493)) ([e9d8757](https://github.com/n8n-io/n8n/commit/e9d875701046f088724f8f0edfc89ae78976c038))
* **core:** Ensure database fallback when Redis cache fails on webhook lookup ([#21872](https://github.com/n8n-io/n8n/issues/21872)) ([7716212](https://github.com/n8n-io/n8n/commit/77162128ff77b47aa6073a110ecf06b5bab559d0))
* **core:** Fix type issues for execution context ([#21852](https://github.com/n8n-io/n8n/issues/21852)) ([1098db4](https://github.com/n8n-io/n8n/commit/1098db4f18f7d5b6ba7dc5d436612acfdade4bef))
* **core:** Validate package version when installing community node ([#21886](https://github.com/n8n-io/n8n/issues/21886)) ([6eeb739](https://github.com/n8n-io/n8n/commit/6eeb739c9283e4691f5754494dcd223319a74f2c))
* **editor:** Avoid node overlap when adding Loop node between existing nodes ([#21419](https://github.com/n8n-io/n8n/issues/21419)) ([94e5b1e](https://github.com/n8n-io/n8n/commit/94e5b1eef083335e76919307373b0672d5c58ed5))
* **editor:** Filtering for archived workflows in error workflows list ([#21874](https://github.com/n8n-io/n8n/issues/21874)) ([cc3f31a](https://github.com/n8n-io/n8n/commit/cc3f31a808fbee7c33a91afefc3a62f064c91aa9))
* **editor:** Fix command bar keyboard events handing ([#21759](https://github.com/n8n-io/n8n/issues/21759)) ([86f6da3](https://github.com/n8n-io/n8n/commit/86f6da38333848dc2c54aedaffda9591a12f66c1))
* **editor:** Fix node name tooltip in NDV header ([#21760](https://github.com/n8n-io/n8n/issues/21760)) ([6fd8ca9](https://github.com/n8n-io/n8n/commit/6fd8ca9021c33883c3d594e962f006059da5a834))
* **editor:** Make sure `Pin` action works only for pinnabe nodes ([#21723](https://github.com/n8n-io/n8n/issues/21723)) ([cf9eb4e](https://github.com/n8n-io/n8n/commit/cf9eb4e4ef77e81da844e68ba88dbfda9150e398))
* **editor:** Previous nodes' outputs aren't available in expression editor for sub-nodes ([#21730](https://github.com/n8n-io/n8n/issues/21730)) ([bd62be0](https://github.com/n8n-io/n8n/commit/bd62be0409a663a4b086a0c177f35bcb850a6c10))
* **editor:** Provide better output for subnode execution errors ([#21714](https://github.com/n8n-io/n8n/issues/21714)) ([5b2d15e](https://github.com/n8n-io/n8n/commit/5b2d15e78ddf3f62f221da4fc00ceade5cda7ae3))
* **editor:** Refresh insights weekly summary when entering any of the /home routes ([#21859](https://github.com/n8n-io/n8n/issues/21859)) ([c3e6439](https://github.com/n8n-io/n8n/commit/c3e6439303a989e85edfd7745970db7bf20d87dc))
* **editor:** Small UI tweaks for data size warnings ([#21693](https://github.com/n8n-io/n8n/issues/21693)) ([f9404ec](https://github.com/n8n-io/n8n/commit/f9404ecb61f3c623e56fc3eee6cf9010856f50e2))
* **editor:** Take user back to correct project after archive/delete ([#21940](https://github.com/n8n-io/n8n/issues/21940)) ([9ddedb0](https://github.com/n8n-io/n8n/commit/9ddedb03dfa2c096c4fb84400a1871dadc0f9987))
* **editor:** Workflow tags style issue ([#21697](https://github.com/n8n-io/n8n/issues/21697)) ([af7417b](https://github.com/n8n-io/n8n/commit/af7417b88ea1ad239f3f939221af5709455fc252))
* **Embeddings Azure OpenAi Node:** Add proxy agent ([#18663](https://github.com/n8n-io/n8n/issues/18663)) ([0ab07f0](https://github.com/n8n-io/n8n/commit/0ab07f04788e9d4f82516939da7e83d3b3dbef44))
* Enable respond to chat node as tool ([#21253](https://github.com/n8n-io/n8n/issues/21253)) ([49eebcf](https://github.com/n8n-io/n8n/commit/49eebcf9292675f14a481747e0a0af5d9eed1947))
* Fix expr-eval dependency for CVE-2025-12735 ([#21862](https://github.com/n8n-io/n8n/issues/21862)) ([2661162](https://github.com/n8n-io/n8n/commit/2661162238b04b585105be0529df123369b1c0e6))
* **Form Node:** Update mime-types package to handle x-zip-compressed ([#21492](https://github.com/n8n-io/n8n/issues/21492)) ([8a935aa](https://github.com/n8n-io/n8n/commit/8a935aa5c1fcc0eaff1c1edb6b4d2e0b7828c7e1))
* **Form Trigger Node:** Do not translate checkbox values ([#21737](https://github.com/n8n-io/n8n/issues/21737)) ([64137eb](https://github.com/n8n-io/n8n/commit/64137eb4d65efdc94916953e8dee65ff3dbdf132))
* **Git Node:** Disable git hooks by default ([#21797](https://github.com/n8n-io/n8n/issues/21797)) ([4dd853b](https://github.com/n8n-io/n8n/commit/4dd853b2d5fe5e35940c14813da0fa72b6c87136))
* Google Vertex sub-node error handling ([#21504](https://github.com/n8n-io/n8n/issues/21504)) ([cc55fef](https://github.com/n8n-io/n8n/commit/cc55fef2404e03c45665021a59feb9a5345d2d33))
* Improve dev mode output and command reliability in `n8n-node` CLI ([#21231](https://github.com/n8n-io/n8n/issues/21231)) ([970f1b7](https://github.com/n8n-io/n8n/commit/970f1b70702bfda1fa2034082f9a2129917ad066))
* Improve domain validation ([#21534](https://github.com/n8n-io/n8n/issues/21534)) ([404640f](https://github.com/n8n-io/n8n/commit/404640f1e93fe848ec4b90e972a1ea53e33f80e2))
* **MCP Client Tool Node:** DCR not working on cloud ([#21808](https://github.com/n8n-io/n8n/issues/21808)) ([5f419f4](https://github.com/n8n-io/n8n/commit/5f419f414a04a83c9c537e5c0f3dfa42a771afc3))
* **MCP Client Tool Node:** Respect the timeout option ([#21478](https://github.com/n8n-io/n8n/issues/21478)) ([9299a7e](https://github.com/n8n-io/n8n/commit/9299a7ee003865594f9f0d917c2efad4b6b91194))
* **MCP Client Tool Node:** Scope is not working if use credential without DRC on (Scope set but pass to /auth endpoint is null) ([#21520](https://github.com/n8n-io/n8n/issues/21520)) ([d3d2017](https://github.com/n8n-io/n8n/commit/d3d2017dc900fd67aae7ca8b7f25781428ff0e01))
* **MCP Client Tool Node:** Use proxy for MCP calls ([#21720](https://github.com/n8n-io/n8n/issues/21720)) ([d9e2dc2](https://github.com/n8n-io/n8n/commit/d9e2dc2166a749190394e38fad80d423d5b3e682))
* **Merge Node:** Show node icon in V1 ([#21812](https://github.com/n8n-io/n8n/issues/21812)) ([65622e0](https://github.com/n8n-io/n8n/commit/65622e041572fb9b6cd632c81846111f1274739d))
* **Schedule Trigger Node:** Show interval boundaries ([#21732](https://github.com/n8n-io/n8n/issues/21732)) ([6963164](https://github.com/n8n-io/n8n/commit/69631646e124f0b3409dc7b3e26c6196dc56c7b0))
* **Slack Node:** Enable pagination for RLC - listChannels ([#21434](https://github.com/n8n-io/n8n/issues/21434)) ([bd04340](https://github.com/n8n-io/n8n/commit/bd04340f4f969df1b4ff5023affac08ae25a5661))
### Features
* **ai-builder:** Updating prompt suggestions for the workflow builder ([#21690](https://github.com/n8n-io/n8n/issues/21690)) ([72dfa55](https://github.com/n8n-io/n8n/commit/72dfa553a1aa378953a6823f2dd237c144f9baf5))
* **Airtop Node:** Add support for airtop agents ([#21624](https://github.com/n8n-io/n8n/issues/21624)) ([6d30615](https://github.com/n8n-io/n8n/commit/6d30615a4c1eaf7af4c8ae2796e061b1dafa4e9b))
* **Azure AI Search API Node:** Add Azure AI Search Vector Store Node (v2) ([#21892](https://github.com/n8n-io/n8n/issues/21892)) ([9c11c3f](https://github.com/n8n-io/n8n/commit/9c11c3f71ef9dbfb49a26386a359960ce22a376a))
* Block invite acceptance on SSO systems ([#21830](https://github.com/n8n-io/n8n/issues/21830)) ([f73eba7](https://github.com/n8n-io/n8n/commit/f73eba7c86fdaa2cbef5cde6282b7e310ce4d02f))
* **core:** Add ACR parameter to OIDC settings ([#20974](https://github.com/n8n-io/n8n/issues/20974)) ([bc61f94](https://github.com/n8n-io/n8n/commit/bc61f94274bef8f4f64c5d014bedef690d218a84))
* **core:** Enable workflow history to all users ([#21240](https://github.com/n8n-io/n8n/issues/21240)) ([e3267b2](https://github.com/n8n-io/n8n/commit/e3267b257ab8ff9ee4c5fc85638eb1d771514a71))
* **core:** Implement all breaking changes rules to v2 ([#21217](https://github.com/n8n-io/n8n/issues/21217)) ([363a777](https://github.com/n8n-io/n8n/commit/363a7773b8f2d2b09466bb60fa7cd19bd6776757))
* **core:** Improve workflows text search ([#21738](https://github.com/n8n-io/n8n/issues/21738)) ([35488e7](https://github.com/n8n-io/n8n/commit/35488e7acc99b0aa8e25b3b499a3cb7d22c9ae5d))
* **core:** Return WWW-Authenticate header in `/mcp-server/http` endpoint ([#21686](https://github.com/n8n-io/n8n/issues/21686)) ([8ed3486](https://github.com/n8n-io/n8n/commit/8ed34862bb6304d0434c0c90155f8fcc1f3c8abd))
* Disable user invites on SSO systems ([#21806](https://github.com/n8n-io/n8n/issues/21806)) ([3cdfff7](https://github.com/n8n-io/n8n/commit/3cdfff7e6cbbc82111dda0303b7a7a1d8b111c28))
* **editor:** Add missing doc url from backend response. remove feature flag ([#21936](https://github.com/n8n-io/n8n/issues/21936)) ([8e5e596](https://github.com/n8n-io/n8n/commit/8e5e5965b1129eb37cc5144d1617377a26d04fc7))
* **editor:** Auto-select operator type when drag and dropping values ([#21260](https://github.com/n8n-io/n8n/issues/21260)) ([707da6c](https://github.com/n8n-io/n8n/commit/707da6cab5486a25c8249f9fc226b5186a3fe49d))
* **editor:** Create breaking change audit page ([#21633](https://github.com/n8n-io/n8n/issues/21633)) ([2e27198](https://github.com/n8n-io/n8n/commit/2e27198c96edadb43900c2f719370e6a48c05e17))
* **editor:** Global row search on data table details view ([#21447](https://github.com/n8n-io/n8n/issues/21447)) ([95ab79f](https://github.com/n8n-io/n8n/commit/95ab79f3f8b1c72d8399c93e19bd6df35d859251))
* **editor:** New simplified empty layout ([#21214](https://github.com/n8n-io/n8n/issues/21214)) ([705a781](https://github.com/n8n-io/n8n/commit/705a78156afd714e976c95cf4ef7c507a8e04145))
* Expression editor - ability to preview HTML or Markdown in results pane ([#21408](https://github.com/n8n-io/n8n/issues/21408)) ([c8a29a7](https://github.com/n8n-io/n8n/commit/c8a29a77f26962ed932597da175cd10ea8c5e80a))
* **MCP Client Tool Node:** Add multiple headers authentication option ([#21435](https://github.com/n8n-io/n8n/issues/21435)) ([2a623ea](https://github.com/n8n-io/n8n/commit/2a623eacf32136deeaf4e6e04c571f889ed9dbf8))
* Prevent ldap email based account when there are deplicate emails ([#21745](https://github.com/n8n-io/n8n/issues/21745)) ([b3af602](https://github.com/n8n-io/n8n/commit/b3af602ed08e11591e17597183cca9c2ab1ff37c))
* Support custom encryption keys for imports / exports ([#21863](https://github.com/n8n-io/n8n/issues/21863)) ([040dcdb](https://github.com/n8n-io/n8n/commit/040dcdbfc97459c28407b32a44c0c81425f81ba0))
* Update upgrade wording for variables ([#21939](https://github.com/n8n-io/n8n/issues/21939)) ([e0e15bf](https://github.com/n8n-io/n8n/commit/e0e15bf444045d40739ace9ba8cc6d179eb02b17))
# [1.120.0](https://github.com/n8n-io/n8n/compare/n8n@1.119.0...n8n@1.120.0) (2025-11-10)
### Bug Fixes
* **ai-builder:** Add support for node versions in searching, adding and updating nodes ([#21488](https://github.com/n8n-io/n8n/issues/21488)) ([8270f37](https://github.com/n8n-io/n8n/commit/8270f37df5fa1d14b1eb2f16606a9128aeab74e3))
* **ai-builder:** Reduce "workflow state too big" errors ([#21542](https://github.com/n8n-io/n8n/issues/21542)) ([e5d7fb9](https://github.com/n8n-io/n8n/commit/e5d7fb971d6f8f68480dc8c0ac55c6852e248dbe))
* **API:** Fix returning role as slug on the users api handler ([#21490](https://github.com/n8n-io/n8n/issues/21490)) ([941a54e](https://github.com/n8n-io/n8n/commit/941a54e723768c317d82d034737af6c33e4107b6))
* Change unicode range to support more characters in expression parser ([#21394](https://github.com/n8n-io/n8n/issues/21394)) ([0a799e1](https://github.com/n8n-io/n8n/commit/0a799e1cabe17518dc6aa5a36fc303e79505492c))
* **Code Node:** Update error message when using `.item` in `Run once for all items` mode ([#21416](https://github.com/n8n-io/n8n/issues/21416)) ([306972d](https://github.com/n8n-io/n8n/commit/306972d914c3f698ec1f43e2a0dc0839f06395d0))
* **core:** Column size for token column ([#21609](https://github.com/n8n-io/n8n/issues/21609)) ([8504beb](https://github.com/n8n-io/n8n/commit/8504beb154ef6ec2e6892cca851b0efdae366ddb))
* **core:** Include role in user-invite-email-click ([#21546](https://github.com/n8n-io/n8n/issues/21546)) ([27fd768](https://github.com/n8n-io/n8n/commit/27fd768deb9a1fc4e38cc8a524d224049c210da8))
* **core:** Insights fix same day queries ([#21574](https://github.com/n8n-io/n8n/issues/21574)) ([c100736](https://github.com/n8n-io/n8n/commit/c1007367458f1b0554c4f2b00f6fd907ef23d000))
* **core:** Insights use time aware range when end date is today, and start of day for past ranges ([#21540](https://github.com/n8n-io/n8n/issues/21540)) ([4dc58aa](https://github.com/n8n-io/n8n/commit/4dc58aacf851ab41039a12f8c96eacbe57b6b2cb))
* **editor:** Ensure license activation modal works when used without EULA ([#21681](https://github.com/n8n-io/n8n/issues/21681)) ([4e70050](https://github.com/n8n-io/n8n/commit/4e70050ab250417c12dc018d2ae972018ff4cb85))
* **editor:** Fix button image link in easy AI template sticky note for UK users ([#21527](https://github.com/n8n-io/n8n/issues/21527)) ([74a0b51](https://github.com/n8n-io/n8n/commit/74a0b51c4636b1760a10c5cb83bebbfdbcb8fca7))
* **editor:** Fix hanging logs panel tooltip ([#21631](https://github.com/n8n-io/n8n/issues/21631)) ([53efa28](https://github.com/n8n-io/n8n/commit/53efa2842ebc3b6e015a718e383f6ad4b1cbb107))
* **editor:** Fix main button create variable disable state based on scopes ([#21521](https://github.com/n8n-io/n8n/issues/21521)) ([d2e623e](https://github.com/n8n-io/n8n/commit/d2e623e2050e20e6cc44cb0233958ff0186e1e1e))
* **editor:** Fix preview for json output with long values ([#21412](https://github.com/n8n-io/n8n/issues/21412)) ([f354200](https://github.com/n8n-io/n8n/commit/f354200c84e6c9e43ec34bda56781b58ac61cf44))
* **editor:** Limit telemetry event size to 32kb ([#21312](https://github.com/n8n-io/n8n/issues/21312)) ([b68d3bf](https://github.com/n8n-io/n8n/commit/b68d3bf534fb2e1e4a9c7d5e469852a586185bd2))
* **editor:** Log view doesn't scroll in manual execution ([#21529](https://github.com/n8n-io/n8n/issues/21529)) ([6945e21](https://github.com/n8n-io/n8n/commit/6945e214233fdd3635d1bcb03968350a5d0905df))
* **Embeddings AWS Bedrock Node, AWS Bedrock Chat Model Node:** Fix HTTP proxy ([#21509](https://github.com/n8n-io/n8n/issues/21509)) ([53d91ee](https://github.com/n8n-io/n8n/commit/53d91ee89fbb6d28c63f841ca1b1acd21d6ab66f))
* Ensure workflows and folders updatedAt/createdAt aren't mixed up in project sorting ([#21484](https://github.com/n8n-io/n8n/issues/21484)) ([d9d36bf](https://github.com/n8n-io/n8n/commit/d9d36bf28f361d2c333b375744b4d7b51619e5a9))
* **Google Workspace Admin Node:** Include `changePasswordAtNextLogin`, `password` in update ([#21522](https://github.com/n8n-io/n8n/issues/21522)) ([477ffea](https://github.com/n8n-io/n8n/commit/477ffea4ced772de7aa06033536c074a4720089a))
* Prevent multiple api requests when changing workflow owner ([#21335](https://github.com/n8n-io/n8n/issues/21335)) ([b610e55](https://github.com/n8n-io/n8n/commit/b610e550f76723a33872c8408e9d506112a1c589))
* **SendGrid Node:** Use `/scopes` for credential testing ([#21499](https://github.com/n8n-io/n8n/issues/21499)) ([c5db57f](https://github.com/n8n-io/n8n/commit/c5db57fd8b7fd32009aacb24e12357a6120eeca1))
* **Slack Node:** Prevent invalid array arg on team join ([#20382](https://github.com/n8n-io/n8n/issues/20382)) ([afd40c6](https://github.com/n8n-io/n8n/commit/afd40c67093e551fc5c16dd39c57bf54446e32e3))
### Features
* Add support for mysql / mariadb ([#21525](https://github.com/n8n-io/n8n/issues/21525)) ([9bcad5a](https://github.com/n8n-io/n8n/commit/9bcad5ae2d63cdff0218636a845a1c7556dbb957))
* Add unit tests for getAttributesFromLoginResponse and handleSamlLogin ([#21678](https://github.com/n8n-io/n8n/issues/21678)) ([9e240d6](https://github.com/n8n-io/n8n/commit/9e240d6d748381a441c9f02ea5311da3f229f74b))
* Allow CORS in the discovery endpoints ([#21602](https://github.com/n8n-io/n8n/issues/21602)) ([3070e44](https://github.com/n8n-io/n8n/commit/3070e446bfec16c743cc2ac58c6ca9a9bd9106ee))
* **core:** Adapt breaking changes report data to UI needs ([#21442](https://github.com/n8n-io/n8n/issues/21442)) ([a2a484e](https://github.com/n8n-io/n8n/commit/a2a484ecf23b7c64f9cb98737de0ae202d0b70dc))
* **core:** Add OAuth to MCP server ([#21469](https://github.com/n8n-io/n8n/issues/21469)) ([cd167ac](https://github.com/n8n-io/n8n/commit/cd167ac6db5d30b21b9dbcee2ca870cfcfa0bcbe))
* **core:** Add workflow descriptions ([#21526](https://github.com/n8n-io/n8n/issues/21526)) ([ecc6706](https://github.com/n8n-io/n8n/commit/ecc67062a435a1924efbc0f6be7a03d4076fae19))
* **core:** Just in time role provisioning for SAML login ([#21387](https://github.com/n8n-io/n8n/issues/21387)) ([2eb1de6](https://github.com/n8n-io/n8n/commit/2eb1de6c82cd3d642ff3bbb50489c9c1b552ca4a))
* **editor:** Data size warning in AI Logs input/output sections ([#21555](https://github.com/n8n-io/n8n/issues/21555)) ([09f91a8](https://github.com/n8n-io/n8n/commit/09f91a8f45b702d168b466e183c0f09ce9e7fda4))
* **Extract from File Node:** Add `Skip Records With Errors` option ([#21347](https://github.com/n8n-io/n8n/issues/21347)) ([0ccf470](https://github.com/n8n-io/n8n/commit/0ccf47044a2ba5b94140bfdd2ba36b868091288d))
* Provide data export of access settings when enabling JIT ([#21532](https://github.com/n8n-io/n8n/issues/21532)) ([146e4ad](https://github.com/n8n-io/n8n/commit/146e4ad268c7d0e17af66bd9a00599e034e9ee54))
* **Redis Node:** Add list length (LLEN) operation ([#21420](https://github.com/n8n-io/n8n/issues/21420)) ([b0df438](https://github.com/n8n-io/n8n/commit/b0df43828604c23b6d95ed10465e2951b251355e))
* Use experiment feature flag for SSO provisioning (no changelog) ([#21494](https://github.com/n8n-io/n8n/issues/21494)) ([a2d6c8d](https://github.com/n8n-io/n8n/commit/a2d6c8d65f82b475d891a71382641404bbe36b05))
# [1.119.0](https://github.com/n8n-io/n8n/compare/n8n@1.118.0...n8n@1.119.0) (2025-11-03)
### Bug Fixes
* **AI Agent Node:** AI Agent v3 only sees first tool response item ([#21429](https://github.com/n8n-io/n8n/issues/21429)) ([d35ba6b](https://github.com/n8n-io/n8n/commit/d35ba6b3707085e8618f12724382a69dccdabedf))
* **ai-builder:** Do not show validation issues as tool errors ([#21310](https://github.com/n8n-io/n8n/issues/21310)) ([3290bf0](https://github.com/n8n-io/n8n/commit/3290bf0d92ef5d935ad8d84fa7466e204c0260b6))
* **ai-builder:** Fixing canvas buttons - if workflow builder is enabled always show "build with AI" button ([#21354](https://github.com/n8n-io/n8n/issues/21354)) ([3b53649](https://github.com/n8n-io/n8n/commit/3b536491658719a2b4d2d6c10586acee4d834f98))
* **ai-builder:** Hide the excute and refine dialog in the workflow builder if task was aborted ([#21355](https://github.com/n8n-io/n8n/issues/21355)) ([f79d968](https://github.com/n8n-io/n8n/commit/f79d968151466d383df2fdf7a17caf34ed7e554f))
* **Airtop Node:** Fix file upload and add support for session recording ([#21248](https://github.com/n8n-io/n8n/issues/21248)) ([4e9ee11](https://github.com/n8n-io/n8n/commit/4e9ee11c237d987eda647b01067c3646c7f29790))
* **core:** Add execution hints support for tools ([#21238](https://github.com/n8n-io/n8n/issues/21238)) ([8971a5a](https://github.com/n8n-io/n8n/commit/8971a5af8f9dd52c4c040978e5c56125ffbc934d))
* **core:** Allow dynamic node parameters in Public API schema ([#21345](https://github.com/n8n-io/n8n/issues/21345)) ([eb46201](https://github.com/n8n-io/n8n/commit/eb4620199e7942ad49a0407d8bfa4f209ce167ff))
* **core:** Disable ANSI colors in production debug logs ([#21344](https://github.com/n8n-io/n8n/issues/21344)) ([fa1ae8a](https://github.com/n8n-io/n8n/commit/fa1ae8afd61a0d0a640016034767d07597409459))
* **core:** Ensure reconnect on broker down in native Python runner ([#21475](https://github.com/n8n-io/n8n/issues/21475)) ([fbd60d2](https://github.com/n8n-io/n8n/commit/fbd60d2a076d5baf02223360c8415db63890db45))
* **core:** Fix AI Agent v3 Tool Execution Issues ([#21477](https://github.com/n8n-io/n8n/issues/21477)) ([11531c8](https://github.com/n8n-io/n8n/commit/11531c89365fbd03f87c608012d6aece5f86fc77))
* **core:** Grant admins full data table access ([#21316](https://github.com/n8n-io/n8n/issues/21316)) ([18012d9](https://github.com/n8n-io/n8n/commit/18012d98c8fd6290041a2faae5ea51f82a6ea8e1))
* **core:** Harden security restrictions in native Python runner ([#21263](https://github.com/n8n-io/n8n/issues/21263)) ([7cea1e2](https://github.com/n8n-io/n8n/commit/7cea1e26fe3cf82da0a69d34f2efbc2427156797))
* **core:** Only select executionData from DB when requested ([#21234](https://github.com/n8n-io/n8n/issues/21234)) ([4be0180](https://github.com/n8n-io/n8n/commit/4be0180f59be02e23c220343719a194500a8ba56))
* **core:** Protect against name-mangling in native Python runner ([#21482](https://github.com/n8n-io/n8n/issues/21482)) ([9a56529](https://github.com/n8n-io/n8n/commit/9a56529d5d9b6091ef2a599d190827d78eaa1407))
* **editor:** Fix `Cannot read properties of undefined` error in evaluation loop ([#21225](https://github.com/n8n-io/n8n/issues/21225)) ([27a3ce7](https://github.com/n8n-io/n8n/commit/27a3ce7d63e8801baa6fe2e4aa9bba4cfd26a061))
* **editor:** Fix event propagating and causing multiple telemetry calls for the same event ([#21385](https://github.com/n8n-io/n8n/issues/21385)) ([e551ce8](https://github.com/n8n-io/n8n/commit/e551ce850dcbf11f05b4210a8d663dfab323dbca))
* **editor:** Handle `executionFinished` event correctly for non-persisted executions ([#21333](https://github.com/n8n-io/n8n/issues/21333)) ([8e2f55b](https://github.com/n8n-io/n8n/commit/8e2f55b371b9bba40505ebf147610d5924a6d347))
* **editor:** Hide credentials setup button in read-only mode ([#21351](https://github.com/n8n-io/n8n/issues/21351)) ([638588e](https://github.com/n8n-io/n8n/commit/638588e728f527e83fb3fdfb1902941dea1e6e1c))
* **editor:** Keep loading executions initially until they fill up the sidebar ([#21279](https://github.com/n8n-io/n8n/issues/21279)) ([900b2c3](https://github.com/n8n-io/n8n/commit/900b2c3a6119ab9234c88cc0fc32252a2c72ef12))
* **editor:** Lowercase resource in `Create Workflow/Credential/Folder` button copy ([#21320](https://github.com/n8n-io/n8n/issues/21320)) ([56805ca](https://github.com/n8n-io/n8n/commit/56805ca000297a750a105ad32cfaad6a3bb83059))
* **editor:** Match user pagination options with API limits ([#21377](https://github.com/n8n-io/n8n/issues/21377)) ([b1243ce](https://github.com/n8n-io/n8n/commit/b1243ce06eb1f2ee78dbc2184eb70949359e5f37))
* **editor:** Prevent 'sin' text concatenation in execution display ([#21343](https://github.com/n8n-io/n8n/issues/21343)) ([f88f09c](https://github.com/n8n-io/n8n/commit/f88f09c0b7450c9477205dd7705587bc6b5e9db1))
* **editor:** Swap data table modal button order and fix copy ([#21318](https://github.com/n8n-io/n8n/issues/21318)) ([72ef61c](https://github.com/n8n-io/n8n/commit/72ef61c47953d54064db9533b3267337774d9145))
* Fix inconsistent insight date range query behaviour ([#21368](https://github.com/n8n-io/n8n/issues/21368)) ([440e83b](https://github.com/n8n-io/n8n/commit/440e83bdfc1e0bf822090e144bfff4abcd3dd3e2))
* Fix insights query date range calculation in backend ([#21409](https://github.com/n8n-io/n8n/issues/21409)) ([1a70592](https://github.com/n8n-io/n8n/commit/1a70592b120f7f7acc2800ef878f50fd0119d12b))
* **Google Calendar Node:** Fix event operation ([#21414](https://github.com/n8n-io/n8n/issues/21414)) ([c4b9470](https://github.com/n8n-io/n8n/commit/c4b947042734fa196f8f4e7a57959154d70bea56))
* **OpenAi Node:** Allow `additionalProperties: {}` in tools ([#21328](https://github.com/n8n-io/n8n/issues/21328)) ([866ff1b](https://github.com/n8n-io/n8n/commit/866ff1b504a85ba24e62dc48d7d5c486d2b02d45))
* Update packages for security fixes ([#21375](https://github.com/n8n-io/n8n/issues/21375)) ([c553c4d](https://github.com/n8n-io/n8n/commit/c553c4d56679f9477407c4f6ec394d0b374513d5))
* Update task runner to 1.4.1 ([#21481](https://github.com/n8n-io/n8n/issues/21481)) ([9dea202](https://github.com/n8n-io/n8n/commit/9dea2024cc48db054bf11c8b5ff72905161ec98c))
* **Xero Node:** Fix currency dropdown sending wrong value to API ([#19499](https://github.com/n8n-io/n8n/issues/19499)) ([49fabcf](https://github.com/n8n-io/n8n/commit/49fabcf4c16dc3dd3deabb9be39c9c51216c3f5d))
### Features
* Add credential tests for ConvertKit, FormIo ([#20361](https://github.com/n8n-io/n8n/issues/20361)) ([8ba12d9](https://github.com/n8n-io/n8n/commit/8ba12d98b2491ee508171f604fb4c3be23d0123f))
* **ai-builder:** Categorize prompts for taxonomy approach ([#20862](https://github.com/n8n-io/n8n/issues/20862)) ([409462b](https://github.com/n8n-io/n8n/commit/409462b09d9ff7ed8ced9ddd9fcf3cb63edb5338))
* **ai-builder:** Check sub-nodes are connected to root node ([#21330](https://github.com/n8n-io/n8n/issues/21330)) ([7c0d1db](https://github.com/n8n-io/n8n/commit/7c0d1db43c03c4115820dace7efb4c59cad4eeb5))
* **API:** Improve workflow settings specs in public API ([#21297](https://github.com/n8n-io/n8n/issues/21297)) ([e356d54](https://github.com/n8n-io/n8n/commit/e356d546a1fc1253feeb01afe08f772f5716a979))
* AWS Assume role credentials ([#20626](https://github.com/n8n-io/n8n/issues/20626)) ([f65b6a8](https://github.com/n8n-io/n8n/commit/f65b6a82a95f0fd298422faa5f6cf3f69bbd3249))
* **Bitbucket Trigger Node:** Access token credentials ([#20912](https://github.com/n8n-io/n8n/issues/20912)) ([6ec2c82](https://github.com/n8n-io/n8n/commit/6ec2c820f426b7c87f1f3d0a66d1c6113b0c50ff))
* **core:** Build workflow index on server start and workflow updates ([#21441](https://github.com/n8n-io/n8n/issues/21441)) ([6df508f](https://github.com/n8n-io/n8n/commit/6df508fa1dc08a697c77a2ab6bb56ca25e03b882))
* **core:** Create breaking change audit rule engine ([#20918](https://github.com/n8n-io/n8n/issues/20918)) ([7d4ec36](https://github.com/n8n-io/n8n/commit/7d4ec363f8ee364bf60f6e02d0d1232cb434c24e))
* **core:** Implement EULA acceptance handling in license activation process ([#21095](https://github.com/n8n-io/n8n/issues/21095)) ([ef9d9f4](https://github.com/n8n-io/n8n/commit/ef9d9f43cea569a38c8de38d7700fa157ac0ec72))
* **core:** Introduce distroless runners image ([#21389](https://github.com/n8n-io/n8n/issues/21389)) ([eb62988](https://github.com/n8n-io/n8n/commit/eb629887dc5e1e5d8d4be8c3488cb966c3707662))
* **core:** Only allow relative redirects on SAML login ([#21266](https://github.com/n8n-io/n8n/issues/21266)) ([77badda](https://github.com/n8n-io/n8n/commit/77badda95b69ac07bd067d701b3ea9fbcac39fd9))
* **core:** Use custom agent to handle http(s) proxies ([#21264](https://github.com/n8n-io/n8n/issues/21264)) ([8987659](https://github.com/n8n-io/n8n/commit/8987659813605e562ca0028dd4b16276228652fb))
* Deprecate insights dateRange query filter ([#21233](https://github.com/n8n-io/n8n/issues/21233)) ([ab269f6](https://github.com/n8n-io/n8n/commit/ab269f6bb885d27b4a365c1e65340da4e954ab44))
* **editor:** Add `Create variable` button to project overview ([#21348](https://github.com/n8n-io/n8n/issues/21348)) ([c41eefd](https://github.com/n8n-io/n8n/commit/c41eefd560be7e5cf93ccf96babac2e2bbf9e700))
* **editor:** Add meta information in telemetry property ([#21350](https://github.com/n8n-io/n8n/issues/21350)) ([284939f](https://github.com/n8n-io/n8n/commit/284939ffdc993c02d4c8e18206a12c9e41e5b141))
* **editor:** Allow duplicate and import in workflow menu if user has update permission ([#21338](https://github.com/n8n-io/n8n/issues/21338)) ([64929cf](https://github.com/n8n-io/n8n/commit/64929cf79c7e6aa24c6a111ec40f7a3c56fdaf19))
* **editor:** Auto-select all credentials by default in push modal ([#21216](https://github.com/n8n-io/n8n/issues/21216)) ([cd403bd](https://github.com/n8n-io/n8n/commit/cd403bda12379381b5828ea786824b4c38ac6433))
* **editor:** Dynamic banners support ([#21045](https://github.com/n8n-io/n8n/issues/21045)) ([2cb8e84](https://github.com/n8n-io/n8n/commit/2cb8e843588284bf5bf0a3d9251d5b3b9f182edb))
* **editor:** Implement EULA acceptance modal and integrate with license activation flow ([#21027](https://github.com/n8n-io/n8n/issues/21027)) ([9767afd](https://github.com/n8n-io/n8n/commit/9767afde196b93186a6423e154f651664ec83cb4))
* **editor:** Remove beta labels from Data table feature ([#21322](https://github.com/n8n-io/n8n/issues/21322)) ([8f5bc13](https://github.com/n8n-io/n8n/commit/8f5bc139d875171484aa596ee165c223e1085ddb))
* **Guardrails Node:** Add new node ([#20755](https://github.com/n8n-io/n8n/issues/20755)) ([453f908](https://github.com/n8n-io/n8n/commit/453f90816b1193f042458084f46bb957ca2ae7b1))
* **MCP Client Tool Node:** Add support for OAuth2 with Dynamic Client Registration ([#21034](https://github.com/n8n-io/n8n/issues/21034)) ([6177f67](https://github.com/n8n-io/n8n/commit/6177f67d99468d1c133f54a357681d6af1b08d50))
* **OpenAI Node:** Add Responses API support to chat model ([#20969](https://github.com/n8n-io/n8n/issues/20969)) ([5312358](https://github.com/n8n-io/n8n/commit/5312358ae5aad8dc5a98732595c85a52921fdae3))
* Support dynamic node icons using expressions ([#20814](https://github.com/n8n-io/n8n/issues/20814)) ([71de992](https://github.com/n8n-io/n8n/commit/71de992eb9181543960d6d509efaef6ef5146293))
# [1.118.0](https://github.com/n8n-io/n8n/compare/n8n@1.117.0...n8n@1.118.0) (2025-10-27)
### Bug Fixes
* **AI Agent Node:** Only use ai-tool output when reconstructing steps ([#21061](https://github.com/n8n-io/n8n/issues/21061)) ([255db6a](https://github.com/n8n-io/n8n/commit/255db6a268260b13075dc861c0a96b5bdaad9d3b))
* **AI Agent Node:** Respect maxIterations parameter ([#21081](https://github.com/n8n-io/n8n/issues/21081)) ([4ea2757](https://github.com/n8n-io/n8n/commit/4ea2757df67519c0b7e1de19a35c55d74ed93266))
* **ai-builder:** Auto-generate names only for workflows with default names ([#21162](https://github.com/n8n-io/n8n/issues/21162)) ([4a4d1fe](https://github.com/n8n-io/n8n/commit/4a4d1feffb07a1beef77d2278e2e943909ba4657))
* **ai-builder:** Format user message to avoid markdown formatting errors ([#21033](https://github.com/n8n-io/n8n/issues/21033)) ([70523e1](https://github.com/n8n-io/n8n/commit/70523e19c813ce290aa8bfcb0fc3ecafe0226139))
* **ai-builder:** Prompt input scrolling fix and update max line from 6 -> 10 ([#21165](https://github.com/n8n-io/n8n/issues/21165)) ([3caa5ac](https://github.com/n8n-io/n8n/commit/3caa5ac3a5bfe38b5364be171cf32945978cc8e8))
* **Call n8n Sub-Workflow Tool Node:** Fix return format for execute path ([#21010](https://github.com/n8n-io/n8n/issues/21010)) ([b9b322e](https://github.com/n8n-io/n8n/commit/b9b322edacd556c7d4b8718ae487e8ca04862bc4))
* **core:** Check all parents of subnodes for expression resolution ([#21141](https://github.com/n8n-io/n8n/issues/21141)) ([1c4b838](https://github.com/n8n-io/n8n/commit/1c4b838512fdd47e1591f0791d4fe2bf93685de2))
* **core:** Handle rejected promises from Rudderstack SDK ([#21083](https://github.com/n8n-io/n8n/issues/21083)) ([520a9d4](https://github.com/n8n-io/n8n/commit/520a9d4940f450bb2bfb30d7878041dc86a2cf79))
* **editor:** Close NDV on AI Builder message ([#21158](https://github.com/n8n-io/n8n/issues/21158)) ([f37864e](https://github.com/n8n-io/n8n/commit/f37864e09918ee6680447033cf4e1e7dde213a71))
* **editor:** Fix tool params schema for object and array fields ([#20956](https://github.com/n8n-io/n8n/issues/20956)) ([b2e0f8c](https://github.com/n8n-io/n8n/commit/b2e0f8cdc0058fb764c37964864858327405f2d1))
* **editor:** Icon picker always on top ([#21116](https://github.com/n8n-io/n8n/issues/21116)) ([9d764d0](https://github.com/n8n-io/n8n/commit/9d764d06e3b8a713e33c6b305cd71990b612f5e5))
* **HTTP Request Node:** Fix expression value handling in `url` parameter ([#21101](https://github.com/n8n-io/n8n/issues/21101)) ([0a82e8d](https://github.com/n8n-io/n8n/commit/0a82e8d23bab97c1367a63b7cd35646d7f8781d0))
* **Merge Node:** Block file access for alasql ([#20858](https://github.com/n8n-io/n8n/issues/20858)) ([79f1cca](https://github.com/n8n-io/n8n/commit/79f1cca9af0ad92b08356cbd2f70d83f512edb6e))
* **n8n Form Node:** From trigger selection ([#21069](https://github.com/n8n-io/n8n/issues/21069)) ([8b42820](https://github.com/n8n-io/n8n/commit/8b428201638b9fe20ac22a2e797256a07a9b28ca))
* **OpenAI Node:** Don't include function calls when conversation id is used ([#21047](https://github.com/n8n-io/n8n/issues/21047)) ([2fff388](https://github.com/n8n-io/n8n/commit/2fff38827ae0e61bad951bfe36a5c40ca9e86aac))
* Pin `@lezer/common` version ([#21074](https://github.com/n8n-io/n8n/issues/21074)) ([30ac847](https://github.com/n8n-io/n8n/commit/30ac8476a240b5a33c9ea429483c652e5eacccf9))
* Sanitize cURL URLs with <PLACEHOLDER> to {PLACEHOLDER} ([#21032](https://github.com/n8n-io/n8n/issues/21032)) ([1e2bd08](https://github.com/n8n-io/n8n/commit/1e2bd08edc81c8a6778143f2d979dc243bd05ac6))
* Update playwright version ([#21096](https://github.com/n8n-io/n8n/issues/21096)) ([e61de6f](https://github.com/n8n-io/n8n/commit/e61de6fc0dc42cc41e5a3a96f0ed7e04361fe969))
* Use correct enum import in CLI node templates ([#20950](https://github.com/n8n-io/n8n/issues/20950)) ([1853108](https://github.com/n8n-io/n8n/commit/18531086a91a1f7e0c503b87deee1ca2e2da668d))
### Features
* Add logic to parse and apply global roles for sso provisioning ([#21007](https://github.com/n8n-io/n8n/issues/21007)) ([dc1437f](https://github.com/n8n-io/n8n/commit/dc1437fd0043b67c2d0d8ab9ad5ed76630278314))
* Add past execution schema viewing in NDV ([#20954](https://github.com/n8n-io/n8n/issues/20954)) ([4698b93](https://github.com/n8n-io/n8n/commit/4698b93a5a205f419edf40b423460e72abaa4155))
* Add patch endpoint and reload mechanisms for sso provisioning configuration ([#20904](https://github.com/n8n-io/n8n/issues/20904)) ([ef688de](https://github.com/n8n-io/n8n/commit/ef688de6dcddcb58aca8ab639f7b89ce5ec2f420))
* **ai-builder:** Properly separate system and user prompts in AI nodes ([#21068](https://github.com/n8n-io/n8n/issues/21068)) ([8659a73](https://github.com/n8n-io/n8n/commit/8659a73e31555450753fc8b5b47305cd30ec2000))
* Allow OIDC logins to sync instance roles ([#21055](https://github.com/n8n-io/n8n/issues/21055)) ([fcbf143](https://github.com/n8n-io/n8n/commit/fcbf1433f6b7715be52333b91fc2663e7a8193e7))
* **core:** Add an incrementing version counter for the workflow entity ([#20816](https://github.com/n8n-io/n8n/issues/20816)) ([dd61458](https://github.com/n8n-io/n8n/commit/dd6145879fa7866a510482bb999318b8934977cd))
* **core:** Add programmatic workflow validation in AI workflow builder ([#20998](https://github.com/n8n-io/n8n/issues/20998)) ([fa7cdf3](https://github.com/n8n-io/n8n/commit/fa7cdf39bdd2daff8710460d8c584f7dd43d0966))
* **core:** Handle project variables sync on source control ([#21001](https://github.com/n8n-io/n8n/issues/21001)) ([832774d](https://github.com/n8n-io/n8n/commit/832774db805dc72c313baabb6fc37704fb658f59))
* **core:** Support `N8N_BLOCK_RUNNER_ENV_ACCESS` in native Python runner ([#21215](https://github.com/n8n-io/n8n/issues/21215)) ([8527e4b](https://github.com/n8n-io/n8n/commit/8527e4b6f1b35472ac472e5c3857cb0e072acb0a))
* **editor:** Add custom date range options to insights dashboard ([#20952](https://github.com/n8n-io/n8n/issues/20952)) ([bf873e3](https://github.com/n8n-io/n8n/commit/bf873e37c7c6a5c48d3eb61a1dc78c00f0870bc9))
* **editor:** Remove flag for project variables ([#21037](https://github.com/n8n-io/n8n/issues/21037)) ([8e7eb98](https://github.com/n8n-io/n8n/commit/8e7eb98ec65db50a0f23e25f164a52b9a0e88d77))
* Improve tests for loginUser ([#21144](https://github.com/n8n-io/n8n/issues/21144)) ([85fb6e4](https://github.com/n8n-io/n8n/commit/85fb6e4a8b387e4fc86a2a5a53631638d5d2d04c))
* Only publish provisioning config changes on multi-main ([#21134](https://github.com/n8n-io/n8n/issues/21134)) ([65b6b0e](https://github.com/n8n-io/n8n/commit/65b6b0e4d8d2a2b91ae342055a11388df9d467ee))
* **OpenAI Node:** Remove built-in MCP from V2 ([#21159](https://github.com/n8n-io/n8n/issues/21159)) ([12833ea](https://github.com/n8n-io/n8n/commit/12833eaf42974c0f5d0fb2df801d0e9a7c328457))
* **OpenAI Node:** Remove completions from V2 ([#21078](https://github.com/n8n-io/n8n/issues/21078)) ([1d41e47](https://github.com/n8n-io/n8n/commit/1d41e47af115b0dcd7476235a79140678d69899f))
* Parse and apply provisioned project role mappings ([#21025](https://github.com/n8n-io/n8n/issues/21025)) ([ca624c6](https://github.com/n8n-io/n8n/commit/ca624c65b549f9e8522e1a787acd066e1434c7b8))
* Provision project roles from OIDC SSO ([#21107](https://github.com/n8n-io/n8n/issues/21107)) ([fa4c979](https://github.com/n8n-io/n8n/commit/fa4c979945faf6c836d74198e82fbcb1780e0af9))
* Reduce unauthentication information in settings endpoint ([#21133](https://github.com/n8n-io/n8n/issues/21133)) ([c859f4e](https://github.com/n8n-io/n8n/commit/c859f4e7932695059718ade5e601cf59baf0ac07))
* Support values configuration type for credentials in MongoDB Vector Store ([#20888](https://github.com/n8n-io/n8n/issues/20888)) ([701f4f8](https://github.com/n8n-io/n8n/commit/701f4f8f29f0e141a48f0ee1d3e609a4ac054d55))
* Trigger projects export and import on push and pull ([#20459](https://github.com/n8n-io/n8n/issues/20459)) ([333ce1f](https://github.com/n8n-io/n8n/commit/333ce1f105022ef4a8fae02c4a7952b464c58d09))
* Wire up saml to instance role provisioning ([#21080](https://github.com/n8n-io/n8n/issues/21080)) ([b8a62c8](https://github.com/n8n-io/n8n/commit/b8a62c893f7356e651d1ac1d9d337fa89b05e52a))
# [1.117.0](https://github.com/n8n-io/n8n/compare/n8n@1.116.0...n8n@1.117.0) (2025-10-21)
### Bug Fixes
* **AI Agent Node:** Track source node toolkit tools are executed from ([#20866](https://github.com/n8n-io/n8n/issues/20866)) ([e195677](https://github.com/n8n-io/n8n/commit/e1956779437f6ad538d12710788cbcebbd924c27))
* **ai-builder:** Accept empty expressionValues in builder request ([#20993](https://github.com/n8n-io/n8n/issues/20993)) ([3f9866e](https://github.com/n8n-io/n8n/commit/3f9866e8f69c7ac69e5d7713d7967baa29feef99))
* **ai-builder:** AI builder memory consumption issue ([#20982](https://github.com/n8n-io/n8n/issues/20982)) ([b4a92f3](https://github.com/n8n-io/n8n/commit/b4a92f32f607394788be5f389371f1e8020d0ed2))
* **ai-builder:** Assistant side panel open without blocking calls ([#20714](https://github.com/n8n-io/n8n/issues/20714)) ([39b7966](https://github.com/n8n-io/n8n/commit/39b79665ab688417999b6a903d95bfb38733a7e6))
* **ai-builder:** Submit AI workflow builder prompt on enter, newline on shift+enter ([#20863](https://github.com/n8n-io/n8n/issues/20863)) ([5b5cef8](https://github.com/n8n-io/n8n/commit/5b5cef8e00d8868951bb62c44f82d0634f812448))
* **Call n8n Sub-Workflow Tool Node:** Return structured data from Workflow Tool when called by engine ([#20869](https://github.com/n8n-io/n8n/issues/20869)) ([44d1835](https://github.com/n8n-io/n8n/commit/44d1835797173efc8e089a9fbd34c14bc21eef8d))
* **core:** Fix folders file overwrite for admin on push ([#20813](https://github.com/n8n-io/n8n/issues/20813)) ([4a3e7d7](https://github.com/n8n-io/n8n/commit/4a3e7d7aecf094c7bb6b04b214cf264cd4c96632))
* **core:** Fix normalization in per-item mode in native Python runner ([#20750](https://github.com/n8n-io/n8n/issues/20750)) ([2224647](https://github.com/n8n-io/n8n/commit/2224647fa6cf5a1ec517bdadca3ee8c2fa096016))
* **core:** Prevent binary payload duplication in JS runner ([#20753](https://github.com/n8n-io/n8n/issues/20753)) ([f193b7a](https://github.com/n8n-io/n8n/commit/f193b7a619a5695efb019d3ed8c51f52f1002771))
* **core:** Prevent duplicate nodeExecuteBefore events for agent nodes ([#20726](https://github.com/n8n-io/n8n/issues/20726)) ([d4cd6bf](https://github.com/n8n-io/n8n/commit/d4cd6bf79fe9036995319adee541d887129b2cf9))
* **core:** Proxy all HTTP traffic instead of only axios ([#20614](https://github.com/n8n-io/n8n/issues/20614)) ([f41b864](https://github.com/n8n-io/n8n/commit/f41b86402e59668c8354c0ca5dfa10eea9763afb))
* **core:** Set rfc3164 option to false for syslog client initialization ([#20664](https://github.com/n8n-io/n8n/issues/20664)) ([bdcb049](https://github.com/n8n-io/n8n/commit/bdcb049859e8ebd1b5f8c6d87aa9243f7528aa00))
* **core:** Stop task process correctly in native Python runner ([#20840](https://github.com/n8n-io/n8n/issues/20840)) ([c560f05](https://github.com/n8n-io/n8n/commit/c560f05a39e0f1eea1a15397530697df05ed6a70))
* Detect resource owner change in source control ([#20811](https://github.com/n8n-io/n8n/issues/20811)) ([e12df06](https://github.com/n8n-io/n8n/commit/e12df06f8d301a441c8322fff0511d959e684a0a))
* **editor:** Disallow drag-and-drop for non-immediate-ancestors of Python code node ([#20773](https://github.com/n8n-io/n8n/issues/20773)) ([0fab5ea](https://github.com/n8n-io/n8n/commit/0fab5ea3d3a4b3555b04d3bfb3bc6d257194aae0))
* **editor:** Enhance error handling and toast notifications in WorkflowDiffModal ([#20812](https://github.com/n8n-io/n8n/issues/20812)) ([2ee3d26](https://github.com/n8n-io/n8n/commit/2ee3d26657dd3575aa90a7ea40f4f3095ffb0b95))
* **editor:** Expand URL validation to accept any HTTPS URL for workflow import instead of requiring a .json extension ([#20509](https://github.com/n8n-io/n8n/issues/20509)) ([f0a3978](https://github.com/n8n-io/n8n/commit/f0a3978b62ed1be2f077d99f5161e4673261e4f7))
* **editor:** Fetch workflow and version in parallel to prevent double render flicker ([#20568](https://github.com/n8n-io/n8n/issues/20568)) ([76969eb](https://github.com/n8n-io/n8n/commit/76969ebc60f32e595e9032d8d1c4788c30d12c13))
* **editor:** Fix command bar test workflow command issue ([#20910](https://github.com/n8n-io/n8n/issues/20910)) ([be27e94](https://github.com/n8n-io/n8n/commit/be27e94cb5fa6aa4038f645f26bd7bc5aa0b51e4))
* **editor:** Hide Execute and refine section when error occurs after workflow update ([#20876](https://github.com/n8n-io/n8n/issues/20876)) ([3d9d19e](https://github.com/n8n-io/n8n/commit/3d9d19ef815d7d3a8953f904cd7c3e274b571f6c))
* **editor:** Update ProjectSettings UI to always show save/cancel buttons and add search functionality for members ([#20828](https://github.com/n8n-io/n8n/issues/20828)) ([fa02786](https://github.com/n8n-io/n8n/commit/fa0278647e8075a1d6936308198f18ced88fd820))
* **editor:** Use existing workflowState when setting node execution issues ([#20777](https://github.com/n8n-io/n8n/issues/20777)) ([ef89640](https://github.com/n8n-io/n8n/commit/ef89640dea0e99fee9c8f0b579e6629560e73f23))
* **Embeddings OpenAI Node:** Add encodingFormat parameter to fix LM Studio compatibility ([#20877](https://github.com/n8n-io/n8n/issues/20877)) ([61b8dbc](https://github.com/n8n-io/n8n/commit/61b8dbc68c7b99d02473c059cefe382ee2953d72))
* Fix source control pull information toast ([#20787](https://github.com/n8n-io/n8n/issues/20787)) ([fbb35d4](https://github.com/n8n-io/n8n/commit/fbb35d432c0c30f9caea8502099dd32fd265bf6a))
* **Gmail Trigger Node:** Prevent missing emails between polling intervals ([#20794](https://github.com/n8n-io/n8n/issues/20794)) ([f3ebf05](https://github.com/n8n-io/n8n/commit/f3ebf05c9289fc0a6e3927b3328893114e9cf299))
* **HTTP Request Node:** Support array in query request parrameters ([#20510](https://github.com/n8n-io/n8n/issues/20510)) ([54dcfde](https://github.com/n8n-io/n8n/commit/54dcfde855b7f4a1456f796b69f2cd44415e4001))
* **LM Chat Azure OpenAI Node:** Set model name to ensure correct internal logic ([#20893](https://github.com/n8n-io/n8n/issues/20893)) ([24f1ad9](https://github.com/n8n-io/n8n/commit/24f1ad92f6250e31e6ac14d708032e97f7728b41))
* **Mistral Cloud Chat Mode Node:** Support JSON response from latest Magistral models ([#20853](https://github.com/n8n-io/n8n/issues/20853)) ([b8f43d3](https://github.com/n8n-io/n8n/commit/b8f43d32cd761fe80ae245b43dfb206e71bd8971))
* MongoDB connection string parameter marked as password ([#20868](https://github.com/n8n-io/n8n/issues/20868)) ([1eccd74](https://github.com/n8n-io/n8n/commit/1eccd74ecfb23ab51233e8a302dab92d9b3d3e64))
* **n8n Form Node:** CSP headers should not be set on response with redirect ([#20864](https://github.com/n8n-io/n8n/issues/20864)) ([7f651ca](https://github.com/n8n-io/n8n/commit/7f651ca5a3162ba0f13242dc934047e32998b33c))
* **Notion Node:** Fix typo in Notion 'Operation' options ([#20809](https://github.com/n8n-io/n8n/issues/20809)) ([b32c8ce](https://github.com/n8n-io/n8n/commit/b32c8cef6e260ffa73d1e6480ab52c01c10a011f))
* **OpenAI Node:** Re-enable list of models for non-OpenAI providers ([#20647](https://github.com/n8n-io/n8n/issues/20647)) ([131a57e](https://github.com/n8n-io/n8n/commit/131a57e0eb6a34a02cd6689ce74e3be8ffeec1c1))
* **OpenAI Node:** Remove `local shell`, update `simplify output` logic ([#20957](https://github.com/n8n-io/n8n/issues/20957)) ([f282b02](https://github.com/n8n-io/n8n/commit/f282b0272ebe38a0b1af891b2ad7af0d751cb08d))
* **OpenAI Node:** Rename "Create" to "Generate" ([#20905](https://github.com/n8n-io/n8n/issues/20905)) ([d20a2e5](https://github.com/n8n-io/n8n/commit/d20a2e585e93e9fa3ccb69ee1221d72a4c13389b))
* **Qdrant Vector Store Node:** Add support for custom content and metadata keys ([#20873](https://github.com/n8n-io/n8n/issues/20873)) ([049c599](https://github.com/n8n-io/n8n/commit/049c599af27f51ef3116c6244d949c6a1da78c44))
* **Sentry.io Node:** Add credential tests and authenticate methods, fix self-hosted credentials ([#20195](https://github.com/n8n-io/n8n/issues/20195)) ([11f8a3f](https://github.com/n8n-io/n8n/commit/11f8a3fb6ec586d9997c3d846680d1d9b10f4df4))
* **Slack Node:** Fix incorrect option name stopping user groups from working ([#20660](https://github.com/n8n-io/n8n/issues/20660)) ([7edbd7d](https://github.com/n8n-io/n8n/commit/7edbd7dea1a70e7961078f775ad03ada4660d228))
### Features
* Add GET /sso/provisioning/config endpoint for sso provisioning config ([#20850](https://github.com/n8n-io/n8n/issues/20850)) ([c40aaa5](https://github.com/n8n-io/n8n/commit/c40aaa557559acc483a6b3fac92152bbe1f33fc8))
* Add new env vars for sso provisioning configuration ([#20749](https://github.com/n8n-io/n8n/issues/20749)) ([28a4e2c](https://github.com/n8n-io/n8n/commit/28a4e2c418be6b398be8f76eceeeb25982117ebb))
* **ai-builder:** Increase prompt limit to 5000 characters ([#20865](https://github.com/n8n-io/n8n/issues/20865)) ([f37e7c2](https://github.com/n8n-io/n8n/commit/f37e7c2ab1fb3389a9750171d07f7d919d42511e))
* **ai-builder:** Optimize Anthropic prompt caching with 4-breakpoint strategy ([#20484](https://github.com/n8n-io/n8n/issues/20484)) ([3afb4db](https://github.com/n8n-io/n8n/commit/3afb4db4ae966604a2f07257bd8c1d597cb3727a))
* **Anthropic Node:** Support custom headers for model requests ([#20253](https://github.com/n8n-io/n8n/issues/20253)) ([7706ec8](https://github.com/n8n-io/n8n/commit/7706ec82c01d9aa22b0c88996bfc77298ae0fcce))
* Assign owner to pulled resources ([#20920](https://github.com/n8n-io/n8n/issues/20920)) ([cec27a8](https://github.com/n8n-io/n8n/commit/cec27a89be29812b0aa51638f5850ac59f7abce9))
* **core:** Add telemetry to monitor project variables usage ([#20593](https://github.com/n8n-io/n8n/issues/20593)) ([fbe2783](https://github.com/n8n-io/n8n/commit/fbe27837f13471934cb4f7fd1b8fe23baecdd509))
* **core:** Database migration to add workflow dependency index table ([#20723](https://github.com/n8n-io/n8n/issues/20723)) ([ed3b19a](https://github.com/n8n-io/n8n/commit/ed3b19a3dedd073ec823fa48563565a8280a327f))
* **editor, ai-builder:** Add resolved expressions context to AI builder ([#20605](https://github.com/n8n-io/n8n/issues/20605)) ([5c74a0b](https://github.com/n8n-io/n8n/commit/5c74a0bb6cc38fe9ba181036db7922de2c33438a))
* **editor:** Add custom roles CRUD UI ([#19224](https://github.com/n8n-io/n8n/issues/19224)) ([d4eb0bb](https://github.com/n8n-io/n8n/commit/d4eb0bb86814acc2886010a746d00203083740aa))
* **editor:** Auto-assign credentials to other nodes ([#20722](https://github.com/n8n-io/n8n/issues/20722)) ([5130c11](https://github.com/n8n-io/n8n/commit/5130c11db78952130ed9a67a03f42321d3e4e942))
* **editor:** Enable rendering verified nodes in preview service ([#20065](https://github.com/n8n-io/n8n/issues/20065)) ([1c07bba](https://github.com/n8n-io/n8n/commit/1c07bba8b3aa5b0125ef27ffc20528711b043acf))
* **editor:** Make the main create resource button aware of the selected tab ([#20725](https://github.com/n8n-io/n8n/issues/20725)) ([7aa2f48](https://github.com/n8n-io/n8n/commit/7aa2f48fde0deda872a6f3c9dd344d1752026c0e))
* **Lemonade Model Node:** Add Lemonade server as a node ([#20557](https://github.com/n8n-io/n8n/issues/20557)) ([f310b13](https://github.com/n8n-io/n8n/commit/f310b134a7a965a243f5d0bc2abd8395f09f64e3))
* **OpenAI Node:** Add Responses API and video generation support ([#20657](https://github.com/n8n-io/n8n/issues/20657)) ([91c09bb](https://github.com/n8n-io/n8n/commit/91c09bb9dd9c0fb76e681316f9dd60b635a2705f))
* **oracleDb Node:** Add Oracle Database SQL Node ([#20867](https://github.com/n8n-io/n8n/issues/20867)) ([b211401](https://github.com/n8n-io/n8n/commit/b21140180e32c335ad0392adf618d33ba05e2838))
* SSO provisioning settings UI ([#20961](https://github.com/n8n-io/n8n/issues/20961)) ([04438f8](https://github.com/n8n-io/n8n/commit/04438f8f97312573dcb1335f0284e82b26662371))
### Performance Improvements
* **core:** Optimize hot paths in native Python runner ([#20844](https://github.com/n8n-io/n8n/issues/20844)) ([a4264f0](https://github.com/n8n-io/n8n/commit/a4264f077dfc412a4571e7fab5165411449c5ec0))
# [1.116.0](https://github.com/n8n-io/n8n/compare/n8n@1.115.0...n8n@1.116.0) (2025-10-13)
### Bug Fixes
* **ai-builder:** Fix loading of Data Table nodes for AI Builder ([#20546](https://github.com/n8n-io/n8n/issues/20546)) ([c21968d](https://github.com/n8n-io/n8n/commit/c21968db3d05d706cba199fb101f04a637132271))
* **core:** Block introspection attributes unconditionally ([#20641](https://github.com/n8n-io/n8n/issues/20641)) ([7ae88f8](https://github.com/n8n-io/n8n/commit/7ae88f836c13d81118231fe80e8329a81bd29e26))
* **core:** Fix broker websocket connection closure on runner heartbeat failure ([#20584](https://github.com/n8n-io/n8n/issues/20584)) ([892cc82](https://github.com/n8n-io/n8n/commit/892cc8254dcab13290d88baacc3ad0a3f1224645))
* **core:** Fix N8N_ENCRYPTION_KEY_FILE environment variable not working ([#20230](https://github.com/n8n-io/n8n/issues/20230)) ([502dd71](https://github.com/n8n-io/n8n/commit/502dd71811df9b9a466584418f41668345791ecc))
* **core:** Fix worker setup completion ([#20495](https://github.com/n8n-io/n8n/issues/20495)) ([8f042a6](https://github.com/n8n-io/n8n/commit/8f042a6c133c9005014f1448470a886b83a76d47))
* **core:** Make sure scopes are deleted after rename ([#20498](https://github.com/n8n-io/n8n/issues/20498)) ([a998e1d](https://github.com/n8n-io/n8n/commit/a998e1d025cf29fd92e229429f00c6c7c773c2dc))
* **core:** Only resume waiting parent workflows ([#20342](https://github.com/n8n-io/n8n/issues/20342)) ([bebccfd](https://github.com/n8n-io/n8n/commit/bebccfdb9388667989d293c205d9620ec6098121))
* **core:** Prevent re-imported scheduled workflow to execute twice ([#20438](https://github.com/n8n-io/n8n/issues/20438)) ([8f7f480](https://github.com/n8n-io/n8n/commit/8f7f48043b28fc41254b6bc71eb13fe025a0eb37))
* **core:** Prevent subscript access to blocked attributes ([#20710](https://github.com/n8n-io/n8n/issues/20710)) ([0026b6b](https://github.com/n8n-io/n8n/commit/0026b6b6b0de5b84fc6ed9b8988d7e9f82a4d23f))
* **core:** Remove logs skipping flag from native Python runner ([#20441](https://github.com/n8n-io/n8n/issues/20441)) ([123a742](https://github.com/n8n-io/n8n/commit/123a7426852b1a3a7a575e51e0c1207764d0ca3f))
* **core:** Retain source overwrite in paired items in tool executions ([#20629](https://github.com/n8n-io/n8n/issues/20629)) ([6f368c3](https://github.com/n8n-io/n8n/commit/6f368c326d219f23cd508c2cf295a804988d15ec))
* **core:** Return insights when only one day is selected ([#20543](https://github.com/n8n-io/n8n/issues/20543)) ([dc72c23](https://github.com/n8n-io/n8n/commit/dc72c23d6ad67d09a48ffdec3de3fda565fccf8e))
* **core:** Solve intermittent typeorm-related build errors for `QueryDeepPartialEntity` ([#20556](https://github.com/n8n-io/n8n/issues/20556)) ([dfb1851](https://github.com/n8n-io/n8n/commit/dfb185151647e79f94d3ba11387d6d6c8c3a11b3))
* **core:** Tighten Sentry error filtering in native Python runner ([#20500](https://github.com/n8n-io/n8n/issues/20500)) ([bcdbada](https://github.com/n8n-io/n8n/commit/bcdbada74b25e2b5c8617bee0f7b62a6c4c97c2e))
* **editor:** Compact large ITaskDataConnections before sending to AI Builder ([#20545](https://github.com/n8n-io/n8n/issues/20545)) ([e58480f](https://github.com/n8n-io/n8n/commit/e58480f901126d9a62fdef5153808329ea2db7f4))
* **editor:** Fix data table add row missing border ([#20514](https://github.com/n8n-io/n8n/issues/20514)) ([799634f](https://github.com/n8n-io/n8n/commit/799634fa45bf792720c12f10e73226d662298014))
* **editor:** Fix inputs when extracting sub-workflows with Split Out nodes ([#19923](https://github.com/n8n-io/n8n/issues/19923)) ([fa64bf1](https://github.com/n8n-io/n8n/commit/fa64bf1ef35773a665fe4a7ffb80d455350f1eab))
* **editor:** Input/output panel in log view shows "N of N item(s)" when nothing matched ([#20224](https://github.com/n8n-io/n8n/issues/20224)) ([9b46bf6](https://github.com/n8n-io/n8n/commit/9b46bf65f38cf16896f98360aa23dd43e5bd2ac6))
* **editor:** Keep source control and user area fixed to bottom of sidebar ([#20530](https://github.com/n8n-io/n8n/issues/20530)) ([0f28b3f](https://github.com/n8n-io/n8n/commit/0f28b3f75676ac30fddc219c2882b747308eac80))
* **editor:** New NDV design tweaks ([#19903](https://github.com/n8n-io/n8n/issues/19903)) ([ca84331](https://github.com/n8n-io/n8n/commit/ca84331761b00ccaa31ae82cc562cb9f43493cfc))
* **editor:** Rename property names in event ([#20537](https://github.com/n8n-io/n8n/issues/20537)) ([32573ca](https://github.com/n8n-io/n8n/commit/32573caae136cd6d9d0e3b403509869141f16925))
* **editor:** Set warning limit to 80% of max limit for data tables ([#20613](https://github.com/n8n-io/n8n/issues/20613)) ([fb94b77](https://github.com/n8n-io/n8n/commit/fb94b779c8de6ead9d6d8d2075a71b1fbb50091f))
* **Extract from File Node:** Fix xlsx data read when readAsString is true ([#20565](https://github.com/n8n-io/n8n/issues/20565)) ([1a8b6e1](https://github.com/n8n-io/n8n/commit/1a8b6e190194c3159ab90af39a1e02658e3d4dd4))
* **Github Node:** Fix GitHub node no longer shows repo owner for Get Issues operation ([#20580](https://github.com/n8n-io/n8n/issues/20580)) ([3d74c3e](https://github.com/n8n-io/n8n/commit/3d74c3ee9ec7f0c812db6b2c39e3e23eb2a14b34))
* **Google Workspace Admin Node:** Rename userId to userKey ([#15940](https://github.com/n8n-io/n8n/issues/15940)) ([65b1df9](https://github.com/n8n-io/n8n/commit/65b1df921063ab3781152fea067062b9b0e0e402))
* **HTTP Request Node:** Body must be stringified while using AWS credentials ([#20526](https://github.com/n8n-io/n8n/issues/20526)) ([c28ac73](https://github.com/n8n-io/n8n/commit/c28ac73e66b7d72b61b6a1b3aab530fdd355cfe4))
* Invalid secret expression value for AWS secret keys containing / ([#20433](https://github.com/n8n-io/n8n/issues/20433)) ([f46b5e1](https://github.com/n8n-io/n8n/commit/f46b5e16406b31fbebfa85e0168f45acb3a291af))
* **Microsoft Graph Security Node:** Add missing offline_access scope to credentials ([#20532](https://github.com/n8n-io/n8n/issues/20532)) ([8dd7c40](https://github.com/n8n-io/n8n/commit/8dd7c402918d8382e9efd32ee11feed8852607b8))
* Pin node version in Docker base image ([#20634](https://github.com/n8n-io/n8n/issues/20634)) ([4d80c2e](https://github.com/n8n-io/n8n/commit/4d80c2e898831539623a9276fc49baf77e881024))
* Prevent runtime import via `__builtins__` dict in native Python runner ([#20628](https://github.com/n8n-io/n8n/issues/20628)) ([09c8559](https://github.com/n8n-io/n8n/commit/09c8559c2c9862420d5c405a21933156b879b1ed))
* **Slack Node:** Add :history scopes to support the 'history' operation in Slack node ([#20523](https://github.com/n8n-io/n8n/issues/20523)) ([88b8719](https://github.com/n8n-io/n8n/commit/88b87191e5677a417fe2ce21a335ea8f01c0d06f))
* **Supabase Node:** Fix issue with execute function was called with incorrect parameters when accessing schema if set by expression ([#20507](https://github.com/n8n-io/n8n/issues/20507)) ([b868284](https://github.com/n8n-io/n8n/commit/b868284851f1900e6154541129519048002e9fc5))
* Update libxml2 ([#20635](https://github.com/n8n-io/n8n/issues/20635)) ([2ac03d0](https://github.com/n8n-io/n8n/commit/2ac03d069133bf69cd63826024e14332fd0bc059))
* Update path.join -> safeJoinPath for compression utils ([#20461](https://github.com/n8n-io/n8n/issues/20461)) ([ad11e77](https://github.com/n8n-io/n8n/commit/ad11e77b42cd805962a2a21bf078569e986368fa))
### Features
* Add docs to `@n8n/eslint-plugin-community-nodes` ([#20266](https://github.com/n8n-io/n8n/issues/20266)) ([6cb36b5](https://github.com/n8n-io/n8n/commit/6cb36b5194ec684ab515ad4b6d78d305ace2b07e))
* Add status check for project json files in git folder ([#20369](https://github.com/n8n-io/n8n/issues/20369)) ([2f38db8](https://github.com/n8n-io/n8n/commit/2f38db86b50d0ff4b2b4cd302d69982601adb200))
* Add strict mode and cloud lint rules to @n8n/node-cli ([#20142](https://github.com/n8n-io/n8n/issues/20142)) ([b1baca5](https://github.com/n8n-io/n8n/commit/b1baca5c6c22115a5769e7a34135a30158d1499d))
* Add support for displayOptions in INodePropertyOptions ([#20184](https://github.com/n8n-io/n8n/issues/20184)) ([fd50563](https://github.com/n8n-io/n8n/commit/fd50563591a77b81677660cd9b3f7d678e6fa04f))
* **ai-builder, editor:** Flag AI builder placeholder parameters and render them on front-end ([#20494](https://github.com/n8n-io/n8n/issues/20494)) ([95d0c45](https://github.com/n8n-io/n8n/commit/95d0c45771fbc9486f7372b7658cae303f5c2329))
* **API:** Add project and projectId fields to get and update a variable project ([#20544](https://github.com/n8n-io/n8n/issues/20544)) ([5bddbed](https://github.com/n8n-io/n8n/commit/5bddbedf2eb5b6363e24e20dccfea267b80001fb))
* **core:** Telemetry for data tables storage limit reached ([#20485](https://github.com/n8n-io/n8n/issues/20485)) ([52ad94f](https://github.com/n8n-io/n8n/commit/52ad94f54cef6fb16d9cff6fb13f935b091c8ac9))
* **core:** Track package_version of community nodes ([#20428](https://github.com/n8n-io/n8n/issues/20428)) ([0da3e14](https://github.com/n8n-io/n8n/commit/0da3e14a521535f8b4c01c4be7ee00f3ba65e8ba))
* **core:** Use project variable in executions ([#20275](https://github.com/n8n-io/n8n/issues/20275)) ([ca69904](https://github.com/n8n-io/n8n/commit/ca69904ad4a4c20f6370f1ebed2c27947a14d6f3))
* Define node's waiting message in the node's description ([#20416](https://github.com/n8n-io/n8n/issues/20416)) ([d03a6c0](https://github.com/n8n-io/n8n/commit/d03a6c08e1ad3c5c5a5228b6e1d0483e1216c49c))
* **editor:** Allow expressions to autocomplete project variables ([#20269](https://github.com/n8n-io/n8n/issues/20269)) ([2a7b341](https://github.com/n8n-io/n8n/commit/2a7b34197a63133cdeef8a9d6794b1e11bc08bc2))
* **editor:** Create new variable page inside overview project page ([#20332](https://github.com/n8n-io/n8n/issues/20332)) ([cd0bbe2](https://github.com/n8n-io/n8n/commit/cd0bbe2d967fb33f212ccc71b933792e001d9a31))
* **editor:** Improve community node tracking ([#20479](https://github.com/n8n-io/n8n/issues/20479)) ([07c60b2](https://github.com/n8n-io/n8n/commit/07c60b23ce5cf2021337d697920577f6ab1948b3))
* **editor:** Introduce `Replace Node` context menu option ([#20287](https://github.com/n8n-io/n8n/issues/20287)) ([273840c](https://github.com/n8n-io/n8n/commit/273840c04216778165ca24dfaaa4176495866578))
* **Ollama Node:** Add Ollama vendor with tool support and image analysis ([#19371](https://github.com/n8n-io/n8n/issues/19371)) ([c257a8f](https://github.com/n8n-io/n8n/commit/c257a8f92233d534f8ec636ea8e017bc966a5804))
* **Redis Vector Store Node:** Redis vector store node implementation ([#19428](https://github.com/n8n-io/n8n/issues/19428)) ([f178a59](https://github.com/n8n-io/n8n/commit/f178a59702ac8803cbf01ac4cf6e0e1500b67f91))
* Roll out Lucide icons to Nodes, remove FontAwesome icons ([#20477](https://github.com/n8n-io/n8n/issues/20477)) ([596cdfe](https://github.com/n8n-io/n8n/commit/596cdfec911054d699ff4343ea99e5d373597f35))
* **Split Out Node:** Incorrect warning fix ([#20468](https://github.com/n8n-io/n8n/issues/20468)) ([fb501d6](https://github.com/n8n-io/n8n/commit/fb501d6ded58b605e26c74499866664fdaba85b2))
# [1.115.0](https://github.com/n8n-io/n8n/compare/n8n@1.114.0...n8n@1.115.0) (2025-10-06)
### Bug Fixes
* `n8n-node` not working on Windows ([#20091](https://github.com/n8n-io/n8n/issues/20091)) ([c3fff03](https://github.com/n8n-io/n8n/commit/c3fff03945869778635d270085122fb01953d3b2))
* Add missing `test` and `authenticate` to OpenWeatherMap, Azure Storage and Netlify credentials ([#20221](https://github.com/n8n-io/n8n/issues/20221)) ([18a5924](https://github.com/n8n-io/n8n/commit/18a592499ffb26f32635a64d94c2fe737562f2ad))
* Allow creation of vector search indexes in MongoDB node ([#20235](https://github.com/n8n-io/n8n/issues/20235)) ([8df5211](https://github.com/n8n-io/n8n/commit/8df5211fa71533ad068499807c27f209a194f01b))
* **Anthropic Chat Model Node:** Fix Sonnet 4.5 not working with default topP ([#20286](https://github.com/n8n-io/n8n/issues/20286)) ([fd7b2a5](https://github.com/n8n-io/n8n/commit/fd7b2a5d45e50ca92a0029154d98bb0226ecdf99))
* **AwsS3 Node:** Return data with continueOnFail ([#19951](https://github.com/n8n-io/n8n/issues/19951)) ([ac0e7e3](https://github.com/n8n-io/n8n/commit/ac0e7e375feaf7390c65c36b8a859af93ff322ca))
* **core:** Add `globals` and `locals` to denylist in native Python runner ([#20172](https://github.com/n8n-io/n8n/issues/20172)) ([77d7408](https://github.com/n8n-io/n8n/commit/77d7408093b0b90aad99d9ef1489cd17438e7c66))
* **core:** Add cancellation reasons ([#20238](https://github.com/n8n-io/n8n/issues/20238)) ([0681e78](https://github.com/n8n-io/n8n/commit/0681e78eb3bb71633582a34fc296f89f280c9536))
* **core:** Capture stderr from task process in native Python runner ([#20180](https://github.com/n8n-io/n8n/issues/20180)) ([7af1a5c](https://github.com/n8n-io/n8n/commit/7af1a5c7b88867d12d3bd213a113a3e3c36b6003))
* **core:** Check all outputs for chat triggers, first output only for webhooks ([#20308](https://github.com/n8n-io/n8n/issues/20308)) ([412322a](https://github.com/n8n-io/n8n/commit/412322a50ea2b0599b3b450de6472c8dd44e31bb))
* **core:** Do not report to Sentry successful ws close in native Python runner ([#20257](https://github.com/n8n-io/n8n/issues/20257)) ([4ca6e4f](https://github.com/n8n-io/n8n/commit/4ca6e4f29fa7374354fd6d19321357b76ef4e484))
* **core:** Fix paired item data retrieval in loops with IF nodes ([#20309](https://github.com/n8n-io/n8n/issues/20309)) ([a9a450b](https://github.com/n8n-io/n8n/commit/a9a450b8d99fa253dc1539ad375c4c2d8df03d6e))
* **core:** Keep submodules of safe modules in native Python runner ([#20284](https://github.com/n8n-io/n8n/issues/20284)) ([7eb8a32](https://github.com/n8n-io/n8n/commit/7eb8a324508d7b61ad8a31359f86a955b50327bc))
* **core:** Make transition self-healing on `SIGTERM` ([#20243](https://github.com/n8n-io/n8n/issues/20243)) ([8fa5b57](https://github.com/n8n-io/n8n/commit/8fa5b57ccf95bed286c0589b93422c6340b58cc8))
* **core:** Prevent race condition on job processing handler registration ([#20219](https://github.com/n8n-io/n8n/issues/20219)) ([cd8926e](https://github.com/n8n-io/n8n/commit/cd8926e851079c3aa1101e160f8027671b04a843))
* **core:** Prevent resolving of raw “$” as stringified function ([#19237](https://github.com/n8n-io/n8n/issues/19237)) ([93df675](https://github.com/n8n-io/n8n/commit/93df67546453f0f7725a63f9aa7650ce4614041d))
* **core:** Show error description in tools ([#19914](https://github.com/n8n-io/n8n/issues/19914)) ([165f74b](https://github.com/n8n-io/n8n/commit/165f74b2a8069283037c6358999625ba15ff47f4))
* **core:** Support log streaming in webhook processors ([#20215](https://github.com/n8n-io/n8n/issues/20215)) ([5ca781f](https://github.com/n8n-io/n8n/commit/5ca781fc4289796683d569f43a873fd0488907d7))
* **core:** Undo backend validation for moving workflow on protected instance ([#20404](https://github.com/n8n-io/n8n/issues/20404)) ([316dafa](https://github.com/n8n-io/n8n/commit/316dafafc075a1b596b686eff0e4a8023a99ad64))
* Disable Builder if license does not have feature ([#20318](https://github.com/n8n-io/n8n/issues/20318)) ([5ad9e81](https://github.com/n8n-io/n8n/commit/5ad9e814a676bec909afef6983e5d75f5ad43c00))
* Disable Execute and Refine button if no credits remaining ([#20248](https://github.com/n8n-io/n8n/issues/20248)) ([4685009](https://github.com/n8n-io/n8n/commit/46850099407182e5e0a72311676b50d0a39996b2))
* **Discord Node:** Add test to Discord Webhook credential ([#20314](https://github.com/n8n-io/n8n/issues/20314)) ([f1a51c4](https://github.com/n8n-io/n8n/commit/f1a51c486ecc588a0114c62cd4d6b737af751c3d))
* **Discord Node:** Fix parseDiscordError to properly display Discord API errors ([#17606](https://github.com/n8n-io/n8n/issues/17606)) ([f331c5e](https://github.com/n8n-io/n8n/commit/f331c5e60015c1b0550ad098b56f5783c09539a6))
* Don't allow moving a readonly workflow ([#20227](https://github.com/n8n-io/n8n/issues/20227)) ([16e4c7e](https://github.com/n8n-io/n8n/commit/16e4c7e16e6afac60d319b1eaa9b01b4740ef8a5))
* **editor:** Add auto-scroll on drag in workflows list ([#20193](https://github.com/n8n-io/n8n/issues/20193)) ([14d0e17](https://github.com/n8n-io/n8n/commit/14d0e1788c02298443aee95a85e8166086782020))
* **editor:** Add rule to prevent undefined components (missing imports) in Vue ([#20273](https://github.com/n8n-io/n8n/issues/20273)) ([2d00ebd](https://github.com/n8n-io/n8n/commit/2d00ebd260ab3dd54fde689e9e0dd1a3ea1ae8b1))
* **editor:** Add upgrade indicators and tooltip for unlicensed project roles ([#20225](https://github.com/n8n-io/n8n/issues/20225)) ([93769f4](https://github.com/n8n-io/n8n/commit/93769f4b331b6fd4fce4418709466800e1305c1e))
* **editor:** Fix `ctrl+s` shortcut when renaming node ([#20310](https://github.com/n8n-io/n8n/issues/20310)) ([324ee62](https://github.com/n8n-io/n8n/commit/324ee620cadf6d48cc75e77426ad2b8cd50465d2))
* **editor:** Fix workflow name not resolving in node expressions preview ([#19979](https://github.com/n8n-io/n8n/issues/19979)) ([44731a9](https://github.com/n8n-io/n8n/commit/44731a98ad29e3d819b4f183b90baaced35e3b6b))
* **editor:** Frontend housekeeping ([#20292](https://github.com/n8n-io/n8n/issues/20292)) ([0bf3151](https://github.com/n8n-io/n8n/commit/0bf3151f080e1feff5d80b48cbb3b0769e24ad7a))
* **editor:** Incorrect toast when starting workflow manually via chat trigger ([#20073](https://github.com/n8n-io/n8n/issues/20073)) ([7d36648](https://github.com/n8n-io/n8n/commit/7d36648e316e83a08493049c856c9804553edb7c))
* **editor:** Show running edge with existing run data as green ([#20204](https://github.com/n8n-io/n8n/issues/20204)) ([3d30f95](https://github.com/n8n-io/n8n/commit/3d30f959c2bc9cec1b6c60ece3d71a955781c3e8))
* Fallback to cli command if http request failed in npm-utils for community packages ([#19413](https://github.com/n8n-io/n8n/issues/19413)) ([2bde2e6](https://github.com/n8n-io/n8n/commit/2bde2e66cd4c83b582db1435327cbf66e5ba9576))
* Fix font paths in design system ([#20256](https://github.com/n8n-io/n8n/issues/20256)) ([7aed600](https://github.com/n8n-io/n8n/commit/7aed600a2e15a09770eac5538138653d0f6aeed5))
* **Grist Node:** Allow filtering for numbers <= 0 ([#20045](https://github.com/n8n-io/n8n/issues/20045)) ([96d3faf](https://github.com/n8n-io/n8n/commit/96d3faf3cec5bf6f4fb275f173fbc8fd46d4e696))
* **S3 Node:** Consider LocationConstraint from GetBucketLocation optional due to many non-compliant S3 implementations ([#15432](https://github.com/n8n-io/n8n/issues/15432)) ([21abe2d](https://github.com/n8n-io/n8n/commit/21abe2d55793f60bdc274b3ed430baf335ccc655))
* **Salesforce Trigger Node:** Cleanup logic update ([#19769](https://github.com/n8n-io/n8n/issues/19769)) ([96a643e](https://github.com/n8n-io/n8n/commit/96a643edf1157f6c96aceb72c3689ad6e690ef06))
* Show Builder's Ask Admin tooltip just for owner ([#20246](https://github.com/n8n-io/n8n/issues/20246)) ([c221833](https://github.com/n8n-io/n8n/commit/c2218334146136acc3560ffe43c46cdb845358ae))
* **Spreadsheet File Node:** Use streams to read xlsx ([#19953](https://github.com/n8n-io/n8n/issues/19953)) ([ef1c079](https://github.com/n8n-io/n8n/commit/ef1c079d3e1260058ad8046ba8ffba65dc7501cd))
* Update links ([#20022](https://github.com/n8n-io/n8n/issues/20022)) ([3ad0706](https://github.com/n8n-io/n8n/commit/3ad070621f991b02fe0aac03abf38160e030b769))
* Use monospace for param preview ([#19975](https://github.com/n8n-io/n8n/issues/19975)) ([02c6a94](https://github.com/n8n-io/n8n/commit/02c6a9485c4153e7a7875cbcd0e752cb57110b4d))
* When a workflow is waiting indefinitely, it now show the correct message ([#20350](https://github.com/n8n-io/n8n/issues/20350)) ([efc2b55](https://github.com/n8n-io/n8n/commit/efc2b5589ae1f88b9f9ff494ea04347ac8612bf3))
### Features
* Import project from json file stored in git folder ([#20294](https://github.com/n8n-io/n8n/issues/20294)) ([65942c3](https://github.com/n8n-io/n8n/commit/65942c3b7b1a643e415efcaba7b582d829623160))
* Add beta label for AI Assistant/Builder ([#20346](https://github.com/n8n-io/n8n/issues/20346)) ([57bea34](https://github.com/n8n-io/n8n/commit/57bea347f07995cd17bcb19dd126ca878b453e5b))
* **core:** Add projectId foreign key to the variable table ([#20058](https://github.com/n8n-io/n8n/issues/20058)) ([bf1b26d](https://github.com/n8n-io/n8n/commit/bf1b26d6a47a57d3f18be0fff315f73e7cb2de0e))
* **core:** Make variable update DTO fields optional for patch and add validation ([#20348](https://github.com/n8n-io/n8n/issues/20348)) ([e23bcfd](https://github.com/n8n-io/n8n/commit/e23bcfd63aac7d06afe372fda08bf1df8fe8a11b))
* **core:** Update variables API to reflect project variables ([#20187](https://github.com/n8n-io/n8n/issues/20187)) ([d5159f0](https://github.com/n8n-io/n8n/commit/d5159f04dc1cac59eb6a6aa27bc2cfb12779e2ae))
* Exclude large data tables by default, allow inclusion with override flag ([#20298](https://github.com/n8n-io/n8n/issues/20298)) ([208027b](https://github.com/n8n-io/n8n/commit/208027b17135c0fc76e21b65cded9cc2777c5476))
* Export projects as json files in source control folder ([#20196](https://github.com/n8n-io/n8n/issues/20196)) ([5c398e4](https://github.com/n8n-io/n8n/commit/5c398e4f2c2ba4d8399f11b2a734c7004c3d48ae))
* GCP secrets with no latest version no longer breaks secret sync ([#20203](https://github.com/n8n-io/n8n/issues/20203)) ([6e39a0d](https://github.com/n8n-io/n8n/commit/6e39a0d3b85f0044d9e46b989bc7a0acd8f47549))
* **Github Node:** Introduce get issues operation on user resource ([#16951](https://github.com/n8n-io/n8n/issues/16951)) ([065bbcf](https://github.com/n8n-io/n8n/commit/065bbcfdc70dfe69eb21923c23d163752cccc164))
* **Gmail Node:** Add "Recipients only" option for reply mode ([#19912](https://github.com/n8n-io/n8n/issues/19912)) ([7570922](https://github.com/n8n-io/n8n/commit/7570922d166fb9c35a69ba3ead8f77b5649fa0ab))
* Implement Agent executing tools in the engine ([#20030](https://github.com/n8n-io/n8n/issues/20030)) ([fadfb75](https://github.com/n8n-io/n8n/commit/fadfb756aefbbd53a465bacfd082092823888a3b))
* PAY-3776 / PAY-3777 add postgres export / import steps ([#20145](https://github.com/n8n-io/n8n/issues/20145)) ([0ffa4d0](https://github.com/n8n-io/n8n/commit/0ffa4d055475a32f11ffcb1471c8b699975e48b3))
* PAY-3778 add import portion to db tests ([#20141](https://github.com/n8n-io/n8n/issues/20141)) ([9360a35](https://github.com/n8n-io/n8n/commit/9360a3594963b82429b878f99bbfb96cd5aab4bf))
* PAY-3859 encrypt decrypt ([#20155](https://github.com/n8n-io/n8n/issues/20155)) ([41bf7be](https://github.com/n8n-io/n8n/commit/41bf7beba4a32d87724d1de262c22cf3e5e41043))
* Use SafePathJoin, Raw SQL queries for insertions ([#20244](https://github.com/n8n-io/n8n/issues/20244)) ([af13918](https://github.com/n8n-io/n8n/commit/af1391853b5c72ce7ea89fbe312e4c61a35bf6cd))
* User streams for compression / decompression ([#20289](https://github.com/n8n-io/n8n/issues/20289)) ([efffc75](https://github.com/n8n-io/n8n/commit/efffc7532976b32e9ca57d8978f4041cb631ebc4))
### Performance Improvements
* **editor:** Omit execution data from executionFinished event ([#20001](https://github.com/n8n-io/n8n/issues/20001)) ([723b9b8](https://github.com/n8n-io/n8n/commit/723b9b8578d9edfb892c642b547109299762ece1))
# [1.114.0](https://github.com/n8n-io/n8n/compare/n8n@1.113.0...n8n@1.114.0) (2025-09-29)
### Bug Fixes
* **ChatTrigger Node:** Chat submit button, fix disabled state coloring ([#20085](https://github.com/n8n-io/n8n/issues/20085)) ([47cbd72](https://github.com/n8n-io/n8n/commit/47cbd72834f8e30d5f509d8d42bc23a88f88ff07))
* **core:** Allow accessing nodes.json in preview mode ([#19920](https://github.com/n8n-io/n8n/issues/19920)) ([a26810b](https://github.com/n8n-io/n8n/commit/a26810b8ae45eef7d0b6ad169c311e5954d842a1))
* **core:** Batch markAsCrashed updates to avoid oversized queries ([#19904](https://github.com/n8n-io/n8n/issues/19904)) ([2db47fd](https://github.com/n8n-io/n8n/commit/2db47fd8c4dc0e083efcd52430180dca5268929c))
* **core:** Ensure cancellation interrupts runner tasks in worker ([#19864](https://github.com/n8n-io/n8n/issues/19864)) ([fac005b](https://github.com/n8n-io/n8n/commit/fac005b1654530f7162d448ba7bde00b50a22392))
* **core:** Fix PKCE OAuth2 flows to work without client secret ([#19601](https://github.com/n8n-io/n8n/issues/19601)) ([199d919](https://github.com/n8n-io/n8n/commit/199d919df149e4aae871987d6957242c95607b04))
* **core:** Iterate over all main output branches when extracting response ([#19963](https://github.com/n8n-io/n8n/issues/19963)) ([0b7db24](https://github.com/n8n-io/n8n/commit/0b7db24070ae4da35b1b0ebf9dc687ee9319dc6d))
* **core:** Only print deprecation notices on start command ([#19987](https://github.com/n8n-io/n8n/issues/19987)) ([4d024ec](https://github.com/n8n-io/n8n/commit/4d024ecb9d1d3806e2d8d0eeed230ebfa3ffde76))
* **core:** Preserve sourceOverwrite in pairedItem data during workflow execution ([#20064](https://github.com/n8n-io/n8n/issues/20064)) ([7de2edd](https://github.com/n8n-io/n8n/commit/7de2eddc8ae960bd80ebaa5e91eaa47a1b278423))
* **core:** Remove Data Table experimental warning log ([#19978](https://github.com/n8n-io/n8n/issues/19978)) ([7273d41](https://github.com/n8n-io/n8n/commit/7273d4150018ee3c76f76146819fca3268fc20c8))
* **core:** Surface workflow config errors to the webhook caller ([#19858](https://github.com/n8n-io/n8n/issues/19858)) ([8327257](https://github.com/n8n-io/n8n/commit/83272570e4b9175c5cbe73d784805a3563485ec2))
* **core:** Use different query to get sqlite data table sizes ([#19878](https://github.com/n8n-io/n8n/issues/19878)) ([24f08e1](https://github.com/n8n-io/n8n/commit/24f08e12f087c55aae4ace6e5c17fbc735ac4c04))
* **editor:** Adjust insight's project filter placeholder ([#19909](https://github.com/n8n-io/n8n/issues/19909)) ([4c0e19f](https://github.com/n8n-io/n8n/commit/4c0e19f637821ac4f2a734c74f7e593ee7951694))
* **editor:** Clean up NDV RunData loading states ([#20082](https://github.com/n8n-io/n8n/issues/20082)) ([874c890](https://github.com/n8n-io/n8n/commit/874c89085b2ff96ef2426181f044f585271d9fd7))
* **editor:** Fix data tables id column sort icon ([#19981](https://github.com/n8n-io/n8n/issues/19981)) ([0475599](https://github.com/n8n-io/n8n/commit/04755999576713150b981cf93d836fa42d4587c1))
* **editor:** Fix empty credential translation ([#20019](https://github.com/n8n-io/n8n/issues/20019)) ([fa66401](https://github.com/n8n-io/n8n/commit/fa664010f169a643eb2d46134cab017f24f7f326))
* **editor:** Fix keyboard navigation in node creator with pre-built agents experiment ([#19853](https://github.com/n8n-io/n8n/issues/19853)) ([a56d6ab](https://github.com/n8n-io/n8n/commit/a56d6ab94627902441f86046c60756c464589764))
* **editor:** Fix reka popover issue when uncontrolled ([#19952](https://github.com/n8n-io/n8n/issues/19952)) ([36df7da](https://github.com/n8n-io/n8n/commit/36df7dada28923409458743b29a37869c93ba93f))
* **editor:** Fix rlc input selection issue when not in list mode ([#20004](https://github.com/n8n-io/n8n/issues/20004)) ([c6443c3](https://github.com/n8n-io/n8n/commit/c6443c39dad2f08251492ea969e7de9f02df8eb7))
* **editor:** Fix UI being blocked from loading while data table limits are being fetched ([#19871](https://github.com/n8n-io/n8n/issues/19871)) ([436c5ab](https://github.com/n8n-io/n8n/commit/436c5abd830732cf12cb5fd5269529c089b7daad))
* **editor:** Improve arrow key navigation in chat message panel ([#19926](https://github.com/n8n-io/n8n/issues/19926)) ([0f19655](https://github.com/n8n-io/n8n/commit/0f19655f95bda214d80aa168788b6923dc50c32e))
* **editor:** Make shortcuts for toggling input/output panel work in the popped out log view ([#19911](https://github.com/n8n-io/n8n/issues/19911)) ([8049da2](https://github.com/n8n-io/n8n/commit/8049da2971978bfffbe2f2151b2aa115e86623c5))
* **editor:** Make the data table size cache longer by default ([#19875](https://github.com/n8n-io/n8n/issues/19875)) ([71cf584](https://github.com/n8n-io/n8n/commit/71cf58473b0a60f0a65b006a39fce551f39c118f))
* **editor:** Mark Workflow Extraction Modal Submit Button as loading and show error toast on error ([#19976](https://github.com/n8n-io/n8n/issues/19976)) ([60560ba](https://github.com/n8n-io/n8n/commit/60560ba6d605b00a0c3a6fe60c7852fe62535f2d))
* **editor:** Reflect Wait node's execution status correctly in log view ([#19898](https://github.com/n8n-io/n8n/issues/19898)) ([a679359](https://github.com/n8n-io/n8n/commit/a6793593b93496f2bee1a43b158ea628983fd5bc))
* **Gmail Trigger Node:** Show warning about multiple items returned ([#19896](https://github.com/n8n-io/n8n/issues/19896)) ([83173fb](https://github.com/n8n-io/n8n/commit/83173fb04ccb59029774c56f092e05bbcccc3199))
* **MCP Client Tool Node:** Change default transport to HTTP Streamable ([#20053](https://github.com/n8n-io/n8n/issues/20053)) ([e0a9de7](https://github.com/n8n-io/n8n/commit/e0a9de7fe3ef1cca52d602d3de030803cb94a29d))
* **Microsoft Excel 365 Node:** Allow appending to empty sheets ([#19642](https://github.com/n8n-io/n8n/issues/19642)) ([c96e6ae](https://github.com/n8n-io/n8n/commit/c96e6aef21e41939bbf7b770f3039d98fcfaf685))
* Only show AI assistant when AI service URL is setup ([#18774](https://github.com/n8n-io/n8n/issues/18774)) ([002f380](https://github.com/n8n-io/n8n/commit/002f380934e5c4b1e6b9b2ef9276bebaa1dda216))
* **OpenAI Node:** Missing header for assistant API calls ([#20060](https://github.com/n8n-io/n8n/issues/20060)) ([a6c7e39](https://github.com/n8n-io/n8n/commit/a6c7e39cb8cd82c3d03af984bdb52a2541140038))
* Pass project ID to the wf execution demo iframe ([#19785](https://github.com/n8n-io/n8n/issues/19785)) ([a0efb97](https://github.com/n8n-io/n8n/commit/a0efb97904d5b9bb3ba70357bc40603d670fa942))
* **Postgres Node:** Fix citext and user defined field type mapping ([#19780](https://github.com/n8n-io/n8n/issues/19780)) ([669a7b5](https://github.com/n8n-io/n8n/commit/669a7b5a9d76f2d3056b7c1ba8ed1a69bd23b1f7))
* **Slack Node:** Reply to message ([#19760](https://github.com/n8n-io/n8n/issues/19760)) ([977d37f](https://github.com/n8n-io/n8n/commit/977d37f6588d4ce884495d1248e8816124a803ad))
* **WhatsApp Business Cloud Node:** Include whatsapp id in contact properties ([#19762](https://github.com/n8n-io/n8n/issues/19762)) ([ea9093c](https://github.com/n8n-io/n8n/commit/ea9093c39c4708929fc860440548a687ff353e9d))
* **Wordpress Node:** Handle `before` query parameter when getting many posts or pages ([#20041](https://github.com/n8n-io/n8n/issues/20041)) ([3963e97](https://github.com/n8n-io/n8n/commit/3963e97c1f8774ee0bb516b2b07288a3f81f199b))
### Features
* Add community node ESLint plugin, use in scanner ([#19660](https://github.com/n8n-io/n8n/issues/19660)) ([b55c95c](https://github.com/n8n-io/n8n/commit/b55c95c863bad90df16fa708b90b9e00a5e64afa))
* Binary data access improvements ([#19629](https://github.com/n8n-io/n8n/issues/19629)) ([182a40e](https://github.com/n8n-io/n8n/commit/182a40e10482988271b388fc399b04dc91767c61))
* **core:** Add custom data range query to insights endpoints ([#20009](https://github.com/n8n-io/n8n/issues/20009)) ([24ea97a](https://github.com/n8n-io/n8n/commit/24ea97a77f6f99807c55153bc294bef8754feab7))
* **core:** Add HTTPS protocol support for environments ([#19482](https://github.com/n8n-io/n8n/issues/19482)) ([9806e6f](https://github.com/n8n-io/n8n/commit/9806e6fc7203ac606e03a0adcf77931399d3ec55))
* **core:** Add startDate and endDate filter query to insights endpoints ([#19954](https://github.com/n8n-io/n8n/issues/19954)) ([7c3e1c9](https://github.com/n8n-io/n8n/commit/7c3e1c9e01606d4dcae2c17dcdde308609c2d021))
* **core:** Adjust n8n email template styling ([#19969](https://github.com/n8n-io/n8n/issues/19969)) ([dbf3284](https://github.com/n8n-io/n8n/commit/dbf328452cca0d4591aae1521f93fec0a1fead85))
* **core:** Throw customized error on assigned custom role deletion ([#19900](https://github.com/n8n-io/n8n/issues/19900)) ([a01e5a5](https://github.com/n8n-io/n8n/commit/a01e5a5bc21979144f7e0a299006556db9817aaf))
* **editor:** Add keyboard shortcut for run data search in the log view and mapper popover ([#19745](https://github.com/n8n-io/n8n/issues/19745)) ([1ca9bbe](https://github.com/n8n-io/n8n/commit/1ca9bbed68c35c1ad68590fd027533692090eadc))
* **editor:** Show node update button in ndv and nodecreator ([#19696](https://github.com/n8n-io/n8n/issues/19696)) ([ef5ec8a](https://github.com/n8n-io/n8n/commit/ef5ec8a6882d6e3590c2dded9d9024dc62e53bac))
* Make project member updates immediate ([#19837](https://github.com/n8n-io/n8n/issues/19837)) ([b59f976](https://github.com/n8n-io/n8n/commit/b59f97631da06dfd825ea7b44718e132421bc399))
* PAY-3774 entities import logic ([#19770](https://github.com/n8n-io/n8n/issues/19770)) ([560c951](https://github.com/n8n-io/n8n/commit/560c95126a78ed1ecf4d3205167d0e5214f5dd79))
* PAY-3775 disable ref to use master ([#20086](https://github.com/n8n-io/n8n/issues/20086)) ([03d351a](https://github.com/n8n-io/n8n/commit/03d351a29063fa79ae1bcf4c40d2a10835352a3c))
* PAY-3775 github action to test exporting to sqlite ([#20069](https://github.com/n8n-io/n8n/issues/20069)) ([0e9464a](https://github.com/n8n-io/n8n/commit/0e9464a32c96b0ad84ec8cf7430dcff6c71fc1ec))
* PAY-3855 ensure latest migrations run ([#19917](https://github.com/n8n-io/n8n/issues/19917)) ([2160c55](https://github.com/n8n-io/n8n/commit/2160c550f93ae2f9fbfbb6fe6b457deff735d397))
### Performance Improvements
* **editor:** Skip calculating log tree entirely when the panel is closed ([#19870](https://github.com/n8n-io/n8n/issues/19870)) ([677cdda](https://github.com/n8n-io/n8n/commit/677cddab31e882792a7053f9bc2c3d685cb9ed66))
# [1.113.0](https://github.com/n8n-io/n8n/compare/n8n@1.112.0...n8n@1.113.0) (2025-09-22)
@ -7629,4 +8336,4 @@ From [Apache 2.0 with Commons Clause](https://github.com/n8n-io/n8n/blob/181ba3c
- **Redis Node:** Add Redis Trigger node and publish operation to regular node ([5c2deb4](https://github.com/n8n-io/n8n/commit/5c2deb468867ec77a05d09ef324d4855210e17d4))
- **Wordpress Node:** Add Status option to Get All operation of Posts resource ([4d4db7f](https://github.com/n8n-io/n8n/commit/4d4db7f805673758dfb379c9e86e98815f265db2))
> **Note:** for changelogs before 0.167.0, refer to the [Release notes](https://docs.n8n.io/reference/release-notes.html) in the documentation.
> **Note:** for changelogs before 0.167.0, refer to the [Release notes](https://docs.n8n.io/release-notes/0-x/) in the documentation.

160
CLAUDE.md
View File

@ -1,159 +1 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with
code in the n8n repository.
## Project Overview
n8n is a workflow automation platform written in TypeScript, using a monorepo
structure managed by pnpm workspaces. It consists of a Node.js backend, Vue.js
frontend, and extensible node-based workflow engine.
## General Guidelines
- Always use pnpm
- We use Linear as a ticket tracking system
- We use Posthog for feature flags
- When starting to work on a new ticket – create a new branch from fresh
master with the name specified in Linear ticket
- When creating a new branch for a ticket in Linear - use the branch name
suggested by linear
- Use mermaid diagrams in MD files when you need to visualise something
## Essential Commands
### Building
Use `pnpm build` to build all packages. ALWAYS redirect the output of the
build command to a file:
```bash
pnpm build > build.log 2>&1
```
You can inspect the last few lines of the build log file to check for errors:
```bash
tail -n 20 build.log
```
### Testing
- `pnpm test` - Run all tests
- `pnpm test:affected` - Runs tests based on what has changed since the last
commit
- `pnpm dev:e2e` - E2E tests in development mode
Running a particular test file requires going to the directory of that test
and running: `pnpm test <test-file>`.
When changing directories, use `pushd` to navigate into the directory and
`popd` to return to the previous directory. When in doubt, use `pwd` to check
your current directory.
### Code Quality
- `pnpm lint` - Lint code
- `pnpm typecheck` - Run type checks
Always run lint and typecheck before committing code to ensure quality.
Execute these commands from within the specific package directory you're
working on (e.g., `cd packages/cli && pnpm lint`). Run the full repository
check only when preparing the final PR. When your changes affect type
definitions, interfaces in `@n8n/api-types`, or cross-package dependencies,
build the system before running lint and typecheck.
## Architecture Overview
**Monorepo Structure:** pnpm workspaces with Turbo build orchestration
### Package Structure
The monorepo is organized into these key packages:
- **`packages/@n8n/api-types`**: Shared TypeScript interfaces between frontend and backend
- **`packages/workflow`**: Core workflow interfaces and types
- **`packages/core`**: Workflow execution engine
- **`packages/cli`**: Express server, REST API, and CLI commands
- **`packages/editor-ui`**: Vue 3 frontend application
- **`packages/@n8n/i18n`**: Internationalization for UI text
- **`packages/nodes-base`**: Built-in nodes for integrations
- **`packages/@n8n/nodes-langchain`**: AI/LangChain nodes
- **`@n8n/design-system`**: Vue component library for UI consistency
- **`@n8n/config`**: Centralized configuration management
## Technology Stack
- **Frontend:** Vue 3 + TypeScript + Vite + Pinia + Storybook UI Library
- **Backend:** Node.js + TypeScript + Express + TypeORM
- **Testing:** Jest (unit) + Playwright (E2E)
- **Database:** TypeORM with SQLite/PostgreSQL/MySQL support
- **Code Quality:** Biome (for formatting) + ESLint + lefthook git hooks
### Key Architectural Patterns
1. **Dependency Injection**: Uses `@n8n/di` for IoC container
2. **Controller-Service-Repository**: Backend follows MVC-like pattern
3. **Event-Driven**: Internal event bus for decoupled communication
4. **Context-Based Execution**: Different contexts for different node types
5. **State Management**: Frontend uses Pinia stores
6. **Design System**: Reusable components and design tokens are centralized in
`@n8n/design-system`, where all pure Vue components should be placed to
ensure consistency and reusability
## Key Development Patterns
- Each package has isolated build configuration and can be developed independently
- Hot reload works across the full stack during development
- Node development uses dedicated `node-dev` CLI tool
- Workflow tests are JSON-based for integration testing
- AI features have dedicated development workflow (`pnpm dev:ai`)
### TypeScript Best Practices
- **NEVER use `any` type** - use proper types or `unknown`
- **Avoid type casting with `as`** - use type guards or type predicates instead
- **Define shared interfaces in `@n8n/api-types`** package for FE/BE communication
### Error Handling
- Don't use `ApplicationError` class in CLI and nodes for throwing errors,
because it's deprecated. Use `UnexpectedError`, `OperationalError` or
`UserError` instead.
- Import from appropriate error classes in each package
### Frontend Development
- **All UI text must use i18n** - add translations to `@n8n/i18n` package
- **Use CSS variables directly** - never hardcode spacing as px values
- **data-test-id must be a single value** (no spaces or multiple values)
When implementing CSS, refer to @packages/frontend/CLAUDE.md for guidelines on
CSS variables and styling conventions.
### Testing Guidelines
- **Always work from within the package directory** when running tests
- **Mock all external dependencies** in unit tests
- **Confirm test cases with user** before writing unit tests
- **Typecheck is critical before committing** - always run `pnpm typecheck`
- **When modifying pinia stores**, check for unused computed properties
What we use for testing and writing tests:
- For testing nodes and other backend components, we use Jest for unit tests. Examples can be found in `packages/nodes-base/nodes/**/*test*`.
- We use `nock` for server mocking
- For frontend we use `vitest`
- For e2e tests we use `Playwright` and `pnpm dev:e2e`. The old Cypress tests
are being migrated to Playwright, so please use Playwright for new tests.
### Common Development Tasks
When implementing features:
1. Define API types in `packages/@n8n/api-types`
2. Implement backend logic in `packages/cli` module, follow
`@packages/cli/scripts/backend-module/backend-module.guide.md`
3. Add API endpoints via controllers
4. Update frontend in `packages/editor-ui` with i18n support
5. Write tests with proper mocks
6. Run `pnpm typecheck` to verify types
## Github Guidelines
- When creating a PR, use the conventions in
`.github/pull_request_template.md` and
`.github/pull_request_title_conventions.md`.
- Use `gh pr create --draft` to create draft PRs.
- Always reference the Linear ticket in the PR description,
use `https://linear.app/n8n/issue/[TICKET-ID]`
- always link to the github issue if mentioned in the linear ticket.
@AGENTS.md

View File

@ -178,12 +178,6 @@ To start n8n execute:
pnpm start
```
To start n8n with tunnel:
```
./packages/cli/bin/n8n start --tunnel
```
## Development cycle
While iterating on n8n modules code, you can run `pnpm dev`. It will then
@ -338,7 +332,7 @@ Please address the requested changes or provide feedback within 14 days. If ther
- **Naming Convention:**
- Follow [n8n's PR Title Conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md#L36).
- **New Nodes:**
- PRs that introduce new nodes will be **auto-closed** unless they are explicitly requested by the n8n team and aligned with an agreed project scope. However, you can still explore [building your own nodes](https://docs.n8n.io/integrations/creating-nodes/) , as n8n offers the flexibility to create your own custom nodes.
- PRs that introduce new nodes will be **auto-closed** unless they are explicitly requested by the n8n team and aligned with an agreed project scope. However, you can still explore [building your own nodes](https://docs.n8n.io/integrations/creating-nodes/overview/), as n8n offers the flexibility to create your own custom nodes.
- **Typo-Only PRs:**
- Typos are not sufficient justification for a PR and will be rejected.
@ -372,15 +366,15 @@ But when you are working on tests locally, we recommend running your tests with
#### E2E tests
⚠️ You have to run `pnpm cypress:install` to install cypress before running the tests for the first time and to update cypress.
n8n uses [Playwright](https://playwright.dev) for E2E testing.
E2E tests can be started via one of the following commands:
- `pnpm test:e2e:ui`: Start n8n and run e2e tests interactively using built UI code. Does not react to code changes (i.e. runs `pnpm start` and `cypress open`)
- `pnpm test:e2e:dev`: Start n8n in development mode and run e2e tests interactively. Reacts to code changes (i.e. runs `pnpm dev` and `cypress open`)
- `pnpm test:e2e:all`: Start n8n and run e2e tests headless (i.e. runs `pnpm start` and `cypress run --headless`)
- `pnpm --filter=n8n-playwright test:local` - Run tests locally (starts local server on port 5680 and runs UI tests)
- `pnpm --filter=n8n-playwright test:local --ui` - Run tests in interactive UI mode (useful for debugging)
- `pnpm --filter=n8n-playwright test:local --grep="test-name"` - Run specific tests matching pattern
⚠️ Remember to stop your dev server before. Otherwise port binding will fail.
See `packages/testing/playwright/README.md` for more test commands and `packages/testing/playwright/CONTRIBUTING.md` for writing guidelines.
## Releasing
@ -400,7 +394,7 @@ This triggers [another workflow](https://github.com/n8n-io/n8n/actions/workflows
## Create custom nodes
Learn about [building nodes](https://docs.n8n.io/integrations/creating-nodes/) to create custom nodes for n8n. You can create community nodes and make them available using [npm](https://www.npmjs.com/).
Learn about [building nodes](https://docs.n8n.io/integrations/creating-nodes/overview/) to create custom nodes for n8n. You can create community nodes and make them available using [npm](https://www.npmjs.com/).
## Extend documentation

View File

@ -36,7 +36,7 @@ Access the editor at http://localhost:5678
- 📚 [Documentation](https://docs.n8n.io)
- 🔧 [400+ Integrations](https://n8n.io/integrations)
- 💡 [Example Workflows](https://n8n.io/workflows)
- 🤖 [AI & LangChain Guide](https://docs.n8n.io/langchain/)
- 🤖 [AI & LangChain Guide](https://docs.n8n.io/advanced-ai/)
- 👥 [Community Forum](https://community.n8n.io)
- 📖 [Community Tutorials](https://community.n8n.io/c/tutorials/28)
@ -55,7 +55,7 @@ n8n is [fair-code](https://faircode.io) distributed under the [Sustainable Use L
[Enterprise licenses](mailto:license@n8n.io) available for additional features and support.
Additional information about the license model can be found in the [docs](https://docs.n8n.io/reference/license/).
Additional information about the license model can be found in the [docs](https://docs.n8n.io/sustainable-use-license/).
## Contributing

View File

@ -1,4 +1,3 @@
## Reporting a Vulnerability
Please report (suspected) security vulnerabilities to **[security@n8n.io](mailto:security@n8n.io)**. You will receive a response from
us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.
If you discover a (suspected) security vulnerability, please report it through our [Vulnerability Disclosure Program](https://n8n.notion.site/n8n-vulnerability-disclosure-program).

View File

@ -13,9 +13,15 @@ github_checks:
annotations: false
flags:
tests:
paths:
- '**'
backend-unit:
carryforward: true
backend-integration:
carryforward: true
nodes-unit:
carryforward: true
frontend:
carryforward: true
frontend-e2e:
carryforward: true
component_management:

115
cubic.yaml Normal file
View File

@ -0,0 +1,115 @@
# yaml-language-server: $schema=https://cubic.dev/schema/cubic-repository-config.schema.json
# cubic.yaml
# This file configures AI review behavior, ignore patterns, PR descriptions, and custom rules.
# Place this file in your repository root to version-control your AI review settings.
# Settings defined here take precedence over UI-configured settings.
# See https://docs.cubic.dev/configure/cubic-yaml for documentation.
version: 1
reviews:
enabled: true
sensitivity: medium
incremental_commits: true
show_ai_feedback_buttons: false
custom_instructions: |-
## Step 1: Fetch Current Guidelines
1. Fetch the current [CONTRIBUTING.md](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md) from the repository's main branch
2. Navigate to the "Community PR Guidelines" section
3. Use ONLY this live version - ignore any cached/embedded rules
## Step 2: Review Process
Evaluate the PR against the rules in the fetched Community PR Guidelines.
## Step 3: Test Requirement Interpretation
BE REASONABLE when evaluating test coverage:
**PASS if:**
- Core functionality has tests
- Critical paths are tested
- Coverage is reasonable (not necessarily 100%)
**DO NOT require tests for:**
- Exports, types, configs
- Metadata files
- Version files
Approve if reasonably tested. Let humans handle edge cases.
custom_rules:
- name: Prefer Typeguards over Type casting
description: |-
- Rule Statement:
Never use `as` keyword for type narrowing; instead, prefer type guards, type annotations, or the `satisfies` keyword.
Only use `as` for the following legitimate cases:
- DOM element assertions (e.g., `document.getElementById('foo') as HTMLButtonElement`)
- Event type assertions (e.g., `e as MouseEvent`)
- Const assertions (`as const`)
- Type widening (e.g., `(['a', 'b'] as string[]).includes('c')`)
- Generic constraints where no alternative exists
For type narrowing, use type guard functions. For type checking without losing inference, prefer `satisfies`. For simple type specification, prefer type annotations (`const x: A = y`) over casting (`const x = y as A`).
- Detection Criteria:
- Identify all usages of the `as` keyword in TypeScript code that perform type assertions (e.g., `expr as SomeType`).
- Exclude any usages in tests such as unit test files (*.test.ts or *.spec.ts) or e2e tests under cypress/ folder.
- Exclude usages where the assertion matches `as const`.
- Exclude assertions where:
- The assertion widens a literal/union type to a broader type (e.g., string literal array to string[])
- The left-hand side is a call to a DOM API (e.g., `document.getElementById`, `document.querySelector`, `document.createElement`)
- The assertion is applied to a variable named `e` or `event` and the type being asserted ends with `Event`
- Flag other usages and suggest alternatives: type guards for narrowing, type annotations for simple typing, or `satisfies` for type verification.
- Setting type of empty value (for example `[] as string[]`)
- Example Violation:
```typescript
function handleShape(shape: Shape) {
// Direct type casting without a type guard
const rect = shape as Rectangle;
return rect.width * rect.height;
}
```
- Example Allowed:
```typescript
// DOM element assertion
const button = document.getElementById('submit') as HTMLButtonElement;
// Event assertion
function handle(e: Event) {
const mouse = e as MouseEvent;
console.log(mouse.clientX);
}
// as const usage
const config = { mode: "readonly" } as const;
// setting types of empty array
const arr = [] as string[]
```
- name: Tests
description: |-
BE REASONABLE when evaluating test coverage:
**PASS if:**
- Core functionality has tests
- Critical paths are tested
- Coverage is reasonable (not necessarily 100%)
**DO NOT require tests for:**
- Exports, types, configs
- Metadata files
- Version files
pr_descriptions:
generate: false
instructions: Each PR is supposed to have a limited scope. In your review, focus on changes made in the PR and avoid pointing out problems you found in the code that already existed.
issues:
fix_with_cubic_buttons: true

3
cypress/.gitignore vendored
View File

@ -1,3 +0,0 @@
videos/
screenshots/
downloads/

View File

@ -1,32 +0,0 @@
## Debugging Flaky End-to-End Tests - Usage
To debug flaky end-to-end (E2E) tests, use the following command:
```bash
pnpm run debug:flaky:e2e -- <grep_filter> <burn_count>
```
**Parameters:**
* `<grep_filter>`: (Optional) A string to filter tests by their `it()` or `describe()` block titles, or by tags if using the `@cypress/grep` plugin. If omitted, all tests will be run.
* `<burn_count>`: (Optional) The number of times to run the filtered tests. Defaults to 5 if not provided.
**Examples:**
1. **Run all tests tagged with `CAT-726` ten times:**
```bash
pnpm run debug:flaky:e2e CAT-726 10
```
2. **Run all tests containing "login" five times (default burn count):**
```bash
pnpm run debug:flaky:e2e login
```
3. **Run all tests five times (default grep and burn count):**
```bash
pnpm run debug:flaky:e2e
```

View File

@ -1,4 +0,0 @@
declare module 'cypress-otp' {
// eslint-disable-next-line import-x/no-default-export
export default function generateOTPToken(secret: string): string;
}

View File

@ -1,7 +0,0 @@
{
"$schema": "../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../biome.jsonc"],
"formatter": {
"ignore": ["fixtures/**"]
}
}

View File

@ -1,18 +0,0 @@
//#region Getters
export const getBecomeTemplateCreatorCta = () => cy.getByTestId('become-template-creator-cta');
export const getCloseBecomeTemplateCreatorCtaButton = () =>
cy.getByTestId('close-become-template-creator-cta');
//#endregion
//#region Actions
export const interceptCtaRequestWithResponse = (becomeCreator: boolean) => {
return cy.intercept('GET', '/rest/cta/become-creator', {
body: becomeCreator,
});
};
//#endregion

View File

@ -1,19 +0,0 @@
export const universalAddButton = () => cy.getByTestId('universal-add');
export const createResource = (
resourceType: 'project' | 'workflow' | 'credential',
projectName: string,
) => {
universalAddButton().click();
cy.getByTestId('navigation-submenu')
.contains(new RegExp(resourceType, 'i'))
.should('be.visible')
.click();
if (resourceType !== 'project') {
cy.getByTestId('navigation-submenu-item')
.contains(new RegExp(projectName))
.should('be.visible')
.click();
}
};

View File

@ -1,114 +0,0 @@
export function getCredentialsPageUrl() {
return '/home/credentials';
}
export const verifyCredentialsListPageIsLoaded = () => {
cy.get('[data-test-id="resources-list-wrapper"], [data-test-id="empty-resources-list"]').should(
'be.visible',
);
};
export const loadCredentialsPage = (credentialsPageUrl: string) => {
cy.visit(credentialsPageUrl);
verifyCredentialsListPageIsLoaded();
};
/**
* Getters - Page
*/
export function getEmptyListCreateCredentialButton() {
return cy.getByTestId('empty-resources-list').find('button');
}
export function getCredentialCards() {
return cy.getByTestId('resources-list-item');
}
/**
* Getters - Modal
*/
export function getNewCredentialModal() {
return cy.getByTestId('selectCredential-modal', { timeout: 5000 });
}
export function getEditCredentialModal() {
return cy.getByTestId('editCredential-modal', { timeout: 5000 });
}
export function getNewCredentialTypeSelect() {
return cy.getByTestId('new-credential-type-select');
}
export function getNewCredentialTypeOption(credentialType: string) {
return cy.getByTestId('new-credential-type-select-option').contains(credentialType);
}
export function getNewCredentialTypeButton() {
return cy.getByTestId('new-credential-type-button');
}
export function getCredentialConnectionParameterInputs() {
return cy.getByTestId('credential-connection-parameter');
}
export function getConnectionParameter(fieldName: string) {
return getCredentialConnectionParameterInputs().find(
`:contains('${fieldName}') .n8n-input input`,
);
}
export function getCredentialSaveButton() {
return cy.getByTestId('credential-save-button', { timeout: 5000 });
}
/**
* Actions - Modal
*/
export function setCredentialName(name: string) {
cy.getByTestId('credential-name').find('span[data-test-id=inline-edit-preview]').click();
cy.getByTestId('credential-name').type(name);
}
export function saveCredential() {
getCredentialSaveButton()
.click({ force: true })
.within(() => {
cy.get('button').should('not.exist');
});
getCredentialSaveButton().should('have.text', 'Saved');
}
export function saveCredentialWithWait() {
cy.intercept('POST', '/rest/credentials').as('saveCredential');
saveCredential();
cy.wait('@saveCredential');
getCredentialSaveButton().should('contain.text', 'Saved');
}
export function closeNewCredentialModal() {
getNewCredentialModal().find('.el-dialog__close').first().click();
}
export function createNewCredential(
type: string,
name: string,
parameter: string,
parameterValue: string,
closeModal = true,
) {
getEmptyListCreateCredentialButton().click();
getNewCredentialModal().should('be.visible');
getNewCredentialTypeSelect().should('be.visible');
getNewCredentialTypeOption(type).click();
getNewCredentialTypeButton().click();
getConnectionParameter(parameter).type(parameterValue);
setCredentialName(name);
saveCredential();
if (closeModal) {
getEditCredentialModal().find('.el-dialog__close').first().click();
}
}

View File

@ -1,46 +0,0 @@
/**
* Getters
*/
export const getExecutionsSidebar = () => cy.getByTestId('executions-sidebar');
export const getWorkflowExecutionPreviewIframe = () => cy.getByTestId('workflow-preview-iframe');
export const getExecutionPreviewBody = () =>
getWorkflowExecutionPreviewIframe()
.its('0.contentDocument.body')
.should((body) => {
expect(body.querySelector('[data-test-id="canvas-wrapper"]')).to.exist;
})
.then((el) => cy.wrap(el));
export const getExecutionPreviewBodyNodes = () =>
getExecutionPreviewBody().findChildByTestId('canvas-node');
export const getExecutionPreviewBodyNodesByName = (name: string) =>
getExecutionPreviewBody().findChildByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0);
export function getExecutionPreviewOutputPanelRelatedExecutionLink() {
return getExecutionPreviewBody().findChildByTestId('related-execution-link');
}
export function getLogsOverviewStatus() {
return getExecutionPreviewBody().findChildByTestId('logs-overview-status');
}
export function getLogEntries() {
return getExecutionPreviewBody().findChildByTestId('logs-overview-body').find('[role=treeitem]');
}
export function getManualChatMessages() {
return getExecutionPreviewBody().find('.chat-messages-list .chat-message');
}
/**
* Actions
*/
export const openExecutionPreviewNode = (name: string) =>
getExecutionPreviewBodyNodesByName(name).dblclick();
export const toggleAutoRefresh = () => cy.getByTestId('auto-refresh-checkbox').click();

View File

@ -1,12 +0,0 @@
export const overrideFeatureFlag = (name: string, value: boolean | string) => {
cy.window().then((win) => {
// If feature flags hasn't been initialized yet, we store the override
// in local storage and it gets loaded when the feature flags are
// initialized.
win.localStorage.setItem('N8N_EXPERIMENT_OVERRIDES', JSON.stringify({ [name]: value }));
if (win.featureFlags) {
win.featureFlags.override(name, value);
}
});
};

View File

@ -1,553 +0,0 @@
import { errorToast, successToast } from '../pages/notifications';
/**
* Getters
*/
export function getPersonalProjectMenuItem() {
return cy.getByTestId('project-personal-menu-item');
}
export function getOverviewMenuItem() {
return cy.getByTestId('menu-item').contains('Overview');
}
export function getAddResourceDropdown() {
return cy.getByTestId('add-resource');
}
export function getFolderCards() {
return cy.getByTestId('folder-card');
}
export function getFolderCard(name: string) {
return cy.getByTestId('folder-card-name').contains(name).closest('[data-test-id="folder-card"]');
}
export function getWorkflowCards() {
return cy.getByTestId('resources-list-item-workflow');
}
export function getWorkflowCard(name: string) {
return cy
.getByTestId('workflow-card-name')
.contains(name)
.closest('[data-test-id="resources-list-item-workflow"]');
}
export function getWorkflowCardActions(name: string) {
return getWorkflowCard(name).find('[data-test-id="workflow-card-actions"]');
}
export function getWorkflowCardActionItem(workflowName: string, actionName: string) {
return getWorkflowCardActions(workflowName)
.find('span[aria-controls]')
.invoke('attr', 'aria-controls')
.then((popperId) => {
return cy.get(`#${popperId}`).find(`[data-test-id="action-${actionName}"]`);
});
}
export function getDuplicateWorkflowModal() {
return cy.getByTestId('duplicate-modal');
}
export function getWorkflowMenu() {
return cy.getByTestId('workflow-menu');
}
export function getAddFolderButton() {
return cy.getByTestId('add-folder-button');
}
export function getListBreadcrumbs() {
return cy.getByTestId('main-breadcrumbs');
}
export function getHomeProjectBreadcrumb() {
return getListBreadcrumbs().findChildByTestId('home-project');
}
export function getListBreadcrumbItem(name: string) {
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item').contains(name);
}
export function getVisibleListBreadcrumbs() {
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item');
}
export function getCurrentBreadcrumb() {
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item-current').find('input');
}
export function getCurrentBreadcrumbText() {
return getCurrentBreadcrumb().invoke('val');
}
export function getMainBreadcrumbsEllipsis() {
return getListBreadcrumbs().findChildByTestId('hidden-items-menu');
}
export function getMainBreadcrumbsEllipsisMenuItems() {
return cy
.getByTestId('hidden-items-menu')
.find('span[aria-controls]')
.invoke('attr', 'aria-controls')
.then((popperId) => {
return cy.get(`#${popperId}`).find('li');
});
}
export function getFolderCardBreadCrumbs(folderName: string) {
return getFolderCard(folderName).find('[data-test-id="folder-card-breadcrumbs"]');
}
export function getFolderCardBreadCrumbsEllipsis(folderName: string) {
return getFolderCardBreadCrumbs(folderName).find('[data-test-id="ellipsis"]');
}
export function getFolderCardHomeProjectBreadcrumb(folderName: string) {
return getFolderCardBreadCrumbs(folderName).find('[data-test-id="folder-card-home-project"]');
}
export function getFolderCardCurrentBreadcrumb(folderName: string) {
return getFolderCardBreadCrumbs(folderName).find('[data-test-id="breadcrumbs-item-current"]');
}
export function getOpenHiddenItemsTooltip() {
return cy.getByTestId('hidden-items-tooltip').filter(':visible');
}
export function getListActionsToggle() {
return cy.getByTestId('folder-breadcrumbs-actions');
}
export function getCanvasBreadcrumbs() {
cy.getByTestId('canvas-breadcrumbs').should('exist');
return cy.getByTestId('canvas-breadcrumbs').findChildByTestId('folder-breadcrumbs');
}
export function getListActionItem(name: string) {
return cy
.getByTestId('folder-breadcrumbs-actions')
.find('span[aria-controls]')
.invoke('attr', 'aria-controls')
.then((popperId) => {
return cy.get(`#${popperId}`).find(`[data-test-id="action-${name}"]`);
});
}
export function getInlineEditInput() {
return cy.getByTestId('inline-edit-input');
}
export function getFolderCardActionToggle(folderName: string) {
return getFolderCard(folderName).find('[data-test-id="folder-card-actions"]');
}
export function getFolderCardActionItem(folderName: string, actionName: string) {
return getFolderCard(folderName)
.findChildByTestId('folder-card-actions')
.filter(':visible')
.find('span[aria-controls]')
.invoke('attr', 'aria-controls')
.then((popperId) => {
return cy.get(`#${popperId}`).find(`[data-test-id="action-${actionName}"]`);
});
}
export function getFolderDeleteModal() {
return cy.getByTestId('deleteFolder-modal');
}
export function getMoveFolderModal() {
return cy.getByTestId('moveFolder-modal');
}
export function getDeleteRadioButton() {
return cy.getByTestId('delete-content-radio');
}
export function getTransferContentRadioButton() {
return cy.getByTestId('transfer-content-radio');
}
export function getConfirmDeleteInput() {
return getFolderDeleteModal().findChildByTestId('delete-data-input').find('input');
}
export function getDeleteFolderModalConfirmButton() {
return getFolderDeleteModal().findChildByTestId('confirm-delete-folder-button');
}
export function getProjectEmptyState() {
return cy.getByTestId('list-empty-state');
}
export function getFolderEmptyState() {
return cy.getByTestId('empty-folder-container');
}
export function getProjectMenuItem(name: string) {
if (name.toLowerCase() === 'personal') {
return getPersonalProjectMenuItem();
}
return cy.getByTestId('project-menu-item').contains(name);
}
export function getMoveToFolderDropdown() {
return cy.getByTestId('move-to-folder-dropdown');
}
export function getMoveToFolderOption(name: string) {
return cy.getByTestId('move-to-folder-option').contains(name);
}
export function getMoveToFolderInput() {
return getMoveToFolderDropdown().find('input');
}
export function getProjectSharingInput() {
return cy.getByTestId('project-sharing-select');
}
export function getProjectSharingOption(name: string) {
return cy.getByTestId('project-sharing-info').contains(name);
}
export function getEmptyFolderDropdownMessage(text: string) {
return cy.get('.el-select-dropdown__empty').contains(text);
}
export function getMoveFolderConfirmButton() {
return cy.getByTestId('confirm-move-folder-button');
}
export function getMoveWorkflowModal() {
return cy.getByTestId('moveFolder-modal');
}
export function getWorkflowCardBreadcrumbs(workflowName: string) {
return getWorkflowCard(workflowName).find('[data-test-id="workflow-card-breadcrumbs"]');
}
export function getWorkflowCardBreadcrumbsEllipsis(workflowName: string) {
return getWorkflowCardBreadcrumbs(workflowName).find('[data-test-id="ellipsis"]');
}
export function getNewFolderNameInput() {
return cy.get('.add-folder-modal').filter(':visible').find('input.el-input__inner');
}
export function getNewFolderModalErrorMessage() {
return cy.get('.el-message-box__errormsg').filter(':visible');
}
export function getProjectTab(tabId: string) {
return cy.getByTestId('project-tabs').find(`#${tabId}`);
}
/**
* Actions
*/
export function goToPersonalProject() {
getPersonalProjectMenuItem().click();
}
export function createFolderInsideFolder(childName: string, parentName: string) {
getFolderCard(parentName).click();
createFolderFromListHeaderButton(childName);
}
export function createFolderFromListHeaderButton(folderName: string) {
getAddFolderButton().click();
createNewFolder(folderName);
}
export function createWorkflowFromEmptyState(workflowName?: string) {
getFolderEmptyState().find('button').contains('Create Workflow').click();
if (workflowName) {
cy.getByTestId('workflow-name-input').type(`{selectAll}{backspace}${workflowName}`, {
delay: 50,
});
}
cy.getByTestId('workflow-save-button').click();
successToast().should('exist');
}
export function createWorkflowFromProjectHeader(folderName?: string, workflowName?: string) {
cy.getByTestId('add-resource-workflow').click();
if (workflowName) {
cy.getByTestId('workflow-name-input').type(`{selectAll}{backspace}${workflowName}`, {
delay: 50,
});
}
cy.getByTestId('workflow-save-button').click();
if (folderName) {
successToast().should(
'contain.text',
`Workflow successfully created in "Personal", within "${folderName}"`,
);
}
}
export function createWorkflowFromListDropdown(workflowName?: string) {
getListActionsToggle().click();
getListActionItem('create_workflow').click();
if (workflowName) {
cy.getByTestId('workflow-name-input').type(`{selectAll}{backspace}${workflowName}`, {
delay: 50,
});
}
cy.getByTestId('workflow-save-button').click();
successToast().should('exist');
}
export function createFolderFromProjectHeader(folderName: string) {
getAddResourceDropdown().click();
cy.getByTestId('action-folder').click();
createNewFolder(folderName);
}
export function createFolderFromListDropdown(folderName: string) {
getListActionsToggle().click();
getListActionItem('create').click();
createNewFolder(folderName);
}
export function createFolderFromCardActions(parentName: string, folderName: string) {
getFolderCardActionToggle(parentName).click();
getFolderCardActionItem(parentName, 'create').click();
createNewFolder(folderName);
}
export function renameFolderFromListActions(folderName: string, newName: string) {
getFolderCard(folderName).click();
getListActionsToggle().click();
getListActionItem('rename').click();
getInlineEditInput().should('be.visible');
getInlineEditInput().type(`${newName}{enter}`, { delay: 50 });
successToast().should('exist');
}
export function renameFolderFromCardActions(folderName: string, newName: string) {
getFolderCardActionToggle(folderName).click();
getFolderCardActionItem(folderName, 'rename').click();
renameFolder(newName);
}
export function duplicateWorkflowFromCardActions(workflowName: string, duplicateName: string) {
getWorkflowCardActions(workflowName).click();
getWorkflowCardActionItem(workflowName, 'duplicate').click();
getDuplicateWorkflowModal().find('input').first().type('{selectall}');
getDuplicateWorkflowModal().find('input').first().type(duplicateName);
getDuplicateWorkflowModal().find('button').contains('Duplicate').click();
errorToast().should('not.exist');
}
export function duplicateWorkflowFromWorkflowPage(duplicateName: string) {
getWorkflowMenu().click();
cy.getByTestId('workflow-menu-item-duplicate').click();
getDuplicateWorkflowModal().find('input').first().type('{selectall}');
getDuplicateWorkflowModal().find('input').first().type(duplicateName);
getDuplicateWorkflowModal().find('button').contains('Duplicate').click();
errorToast().should('not.exist');
}
export function deleteEmptyFolderFromCardDropdown(folderName: string) {
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
getFolderCard(folderName).click();
getListActionsToggle().click();
getListActionItem('delete').click();
cy.wait('@deleteFolder');
successToast().should('contain.text', 'Folder deleted');
}
export function deleteEmptyFolderFromListDropdown(folderName: string) {
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
getFolderCard(folderName).click();
getListActionsToggle().click();
getListActionItem('delete').click();
cy.wait('@deleteFolder');
successToast().should('contain.text', 'Folder deleted');
}
export function deleteFolderWithContentsFromListDropdown(folderName: string) {
getListActionsToggle().click();
getListActionItem('delete').click();
confirmFolderDelete(folderName);
}
export function deleteFolderWithContentsFromCardDropdown(folderName: string) {
getFolderCardActionToggle(folderName).click();
getFolderCardActionItem(folderName, 'delete').click();
confirmFolderDelete(folderName);
}
export function deleteAndTransferFolderContentsFromCardDropdown(
folderName: string,
destinationName: string,
) {
getFolderCardActionToggle(folderName).click();
getFolderCardActionItem(folderName, 'delete').click();
deleteFolderAndMoveContents(folderName, destinationName);
}
export function deleteAndTransferFolderContentsFromListDropdown(destinationName: string) {
getListActionsToggle().click();
getListActionItem('delete').click();
getCurrentBreadcrumbText().then((currentFolderName) => {
deleteFolderAndMoveContents(String(currentFolderName), destinationName);
});
}
export function createNewProject(projectName: string, options: { openAfterCreate?: boolean } = {}) {
cy.getByTestId('universal-add').should('exist').click();
cy.getByTestId('navigation-menu-item').contains('Project').click();
cy.getByTestId('project-settings-name-input').type(projectName, { delay: 50 });
cy.getByTestId('project-settings-save-button').click();
successToast().should('exist');
if (options.openAfterCreate) {
getProjectMenuItem(projectName).click();
}
}
export function moveFolderFromFolderCardActions(folderName: string, destinationName: string) {
getFolderCardActionToggle(folderName).click();
getFolderCardActionItem(folderName, 'move').click();
moveFolder(folderName, destinationName);
}
export function moveFolderFromListActions(folderName: string, destinationName: string) {
getFolderCard(folderName).click();
getListActionsToggle().click();
getListActionItem('move').click();
moveFolder(folderName, destinationName);
}
export function moveWorkflowToFolder(workflowName: string, folderName: string) {
getWorkflowCardActions(workflowName).click();
getWorkflowCardActionItem(workflowName, 'moveToFolder').click();
getMoveFolderModal().should('be.visible');
getMoveToFolderDropdown().click();
getMoveToFolderInput().type(folderName, { delay: 50 });
getMoveToFolderOption(folderName).should('be.visible').click();
getMoveFolderConfirmButton().should('be.enabled').click();
}
export function dragAndDropToFolder(sourceName: string, destinationName: string) {
const draggable = `[data-test-id=draggable]:has([data-resourcename="${sourceName}"])`;
const droppable = `[data-test-id=draggable]:has([data-resourcename="${destinationName}"])`;
cy.get(draggable).trigger('mousedown');
cy.draganddrop(draggable, droppable, { position: 'center' });
}
export function dragAndDropToProjectRoot(sourceName: string) {
const draggable = `[data-test-id=draggable]:has([data-resourcename="${sourceName}"])`;
const droppable = '[data-test-id="home-project"]';
cy.get(draggable).trigger('mousedown');
cy.draganddrop(draggable, droppable, { position: 'center' });
}
/**
* Utils
*/
/**
* Types folder name in the prompt and waits for the folder to be created
* @param name
*/
function createNewFolder(name: string) {
cy.intercept('POST', '/rest/projects/**').as('createFolder');
cy.get('[role=dialog]')
.filter(':visible')
.within(() => {
cy.get('input.el-input__inner').type(name, { delay: 50 });
cy.get('button.btn--confirm').click();
});
cy.wait('@createFolder');
successToast().should('exist');
}
function renameFolder(newName: string) {
cy.intercept('PATCH', '/rest/projects/**').as('renameFolder');
cy.get('[role=dialog]')
.filter(':visible')
.within(() => {
cy.get('input.el-input__inner').type('{selectall}');
cy.get('input.el-input__inner').type(newName, { delay: 50 });
cy.get('button.btn--confirm').click();
});
cy.wait('@renameFolder');
successToast().should('exist');
}
function confirmFolderDelete(folderName: string) {
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
getFolderDeleteModal().should('be.visible');
getDeleteRadioButton().click();
getConfirmDeleteInput().should('be.visible');
getConfirmDeleteInput().type(`delete ${folderName}`, { delay: 50 });
getDeleteFolderModalConfirmButton().should('be.enabled').click();
cy.wait('@deleteFolder');
successToast().contains('Folder deleted').should('exist');
}
function deleteFolderAndMoveContents(folderName: string, destinationName: string) {
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
getFolderDeleteModal().should('be.visible');
getFolderDeleteModal().find('h1').first().contains(`Delete '${folderName}'`);
getTransferContentRadioButton().should('be.visible').click();
getMoveToFolderDropdown().click();
getMoveToFolderInput().type(destinationName);
getMoveToFolderOption(destinationName).click();
getDeleteFolderModalConfirmButton().should('be.enabled').click();
cy.wait('@deleteFolder');
successToast().should('contain.text', `Data transferred to '${destinationName}'`);
}
function moveFolder(folderName: string, destinationName: string) {
cy.intercept('PATCH', '/rest/projects/**').as('moveFolder');
getMoveFolderModal().should('be.visible');
getMoveFolderModal().find('h1').first().contains(`Move folder ${folderName}`);
// The dropdown focuses after a small delay (once modal's slide in animation is done).
// On the component we listen for an event, but here the wait should be very predictable.
cy.wait(500);
// Try to find current folder in the dropdown
// This tests that auto-focus worked as expected
cy.focused().type(folderName, { delay: 50 });
// Should not be available
getEmptyFolderDropdownMessage('No folders found').should('exist');
// Select destination folder
getMoveToFolderInput().type(`{selectall}{backspace}${destinationName}`, {
delay: 50,
});
getMoveToFolderOption(destinationName).should('be.visible').click();
getMoveFolderConfirmButton().should('be.enabled').click();
cy.wait('@moveFolder');
}
export function transferWorkflow(
workflowName: string,
projectName: string,
destinationFolder?: string,
) {
getMoveFolderModal().should('be.visible');
getMoveFolderModal().find('h1').first().contains(`Move workflow ${workflowName}`);
cy.wait(500);
getProjectSharingInput().should('be.visible').click();
cy.focused().type(projectName, { delay: 50 });
getProjectSharingOption(projectName).should('be.visible').click();
if (destinationFolder) {
getMoveToFolderInput().click();
// Select destination folder
cy.focused().type(destinationFolder, { delay: 50 });
getMoveToFolderOption(destinationFolder).should('be.visible').click();
}
getMoveFolderConfirmButton().should('be.enabled').click();
}

View File

@ -1,92 +0,0 @@
/**
* Accessors
*/
export function getOverviewPanel() {
return cy.getByTestId('logs-overview');
}
export function getOverviewPanelBody() {
return cy.getByTestId('logs-overview-body');
}
export function getOverviewStatus() {
return cy.getByTestId('logs-overview-status');
}
export function getLogEntries() {
return cy.getByTestId('logs-overview-body').find('[role=treeitem]');
}
export function getSelectedLogEntry() {
return cy.getByTestId('logs-overview-body').find('[role=treeitem][aria-selected=true]');
}
export function getInputPanel() {
return cy.getByTestId('log-details-input');
}
export function getInputTableRows() {
return cy.getByTestId('log-details-input').find('table tr');
}
export function getInputTbodyCell(row: number, col: number) {
return cy.getByTestId('log-details-input').find('table tr').eq(row).find('td').eq(col);
}
export function getNodeErrorMessageHeader() {
return cy.getByTestId('log-details-output').findChildByTestId('node-error-message');
}
export function getOutputPanel() {
return cy.getByTestId('log-details-output');
}
export function getOutputTableRows() {
return cy.getByTestId('log-details-output').find('table tr');
}
export function getOutputTbodyCell(row: number, col: number) {
return cy.getByTestId('log-details-output').find('table tr').eq(row).find('td').eq(col);
}
/**
* Actions
*/
export function openLogsPanel() {
cy.getByTestId('logs-overview-header').click();
}
export function pressClearExecutionButton() {
cy.getByTestId('logs-overview-header').find('button').contains('Clear execution').click();
}
export function clickLogEntryAtRow(rowIndex: number) {
getLogEntries().eq(rowIndex).click();
}
export function toggleInputPanel() {
cy.getByTestId('log-details-header').contains('Input').click();
}
export function clickOpenNdvAtRow(rowIndex: number) {
getLogEntries().eq(rowIndex).trigger('focus').realHover();
getLogEntries().eq(rowIndex).find('[aria-label="Open..."]').click();
}
export function clickTriggerPartialExecutionAtRow(rowIndex: number) {
getLogEntries().eq(rowIndex).scrollIntoView();
getLogEntries().eq(rowIndex).realHover();
getLogEntries().eq(rowIndex).find('[aria-label="Execute step"]').click({ force: true });
}
export function setInputDisplayMode(mode: 'table' | 'ai' | 'json' | 'schema') {
cy.getByTestId('log-details-input').realHover();
cy.getByTestId('log-details-input').findChildByTestId(`radio-button-${mode}`).click();
}
export function setOutputDisplayMode(mode: 'table' | 'ai' | 'json' | 'schema') {
cy.getByTestId('log-details-output').realHover();
cy.getByTestId('log-details-output').findChildByTestId(`radio-button-${mode}`).click();
}

View File

@ -1,40 +0,0 @@
/**
* Getters
*/
export function getManualChatModal() {
return cy.getByTestId('canvas-chat');
}
export function getManualChatInput() {
return getManualChatModal().get('.chat-inputs textarea');
}
export function getManualChatSendButton() {
return getManualChatModal().get('.chat-input-send-button');
}
export function getManualChatMessages() {
return getManualChatModal().get('.chat-messages-list .chat-message');
}
export function getManualChatModalCloseButton() {
return cy.getByTestId('workflow-chat-button');
}
export function getManualChatDialog() {
return getManualChatModal().getByTestId('workflow-lm-chat-dialog');
}
/**
* Actions
*/
export function sendManualChatMessage(message: string) {
getManualChatInput().type(message);
getManualChatSendButton().click();
}
export function closeManualChatModal() {
getManualChatModalCloseButton().click();
}

View File

@ -1,62 +0,0 @@
/**
* Getters
*/
import { clearNotifications } from '../../pages/notifications';
export function getCredentialConnectionParameterInputs() {
return cy.getByTestId('credential-connection-parameter');
}
export function getCredentialConnectionParameterInputByName(name: string) {
return cy.getByTestId(`parameter-input-${name}`);
}
export function getEditCredentialModal() {
return cy.getByTestId('editCredential-modal', { timeout: 5000 });
}
export function getCredentialSaveButton() {
return cy.getByTestId('credential-save-button', { timeout: 5000 });
}
export function getCredentialDeleteButton() {
return cy.getByTestId('credential-delete-button');
}
export function getCredentialModalCloseButton() {
return getEditCredentialModal().find('.el-dialog__close').first();
}
/**
* Actions
*/
export function setCredentialConnectionParameterInputByName(name: string, value: string) {
getCredentialConnectionParameterInputByName(name).type(value);
}
export function saveCredential() {
getCredentialSaveButton()
.click({ force: true })
.within(() => {
cy.get('button').should('not.exist');
});
getCredentialSaveButton().should('have.text', 'Saved');
}
export function closeCredentialModal() {
getCredentialModalCloseButton().click();
}
export function setCredentialValues(values: Record<string, string>, save = true) {
Object.entries(values).forEach(([key, value]) => {
setCredentialConnectionParameterInputByName(key, value);
});
if (save) {
saveCredential();
closeCredentialModal();
clearNotifications();
}
}

View File

@ -1,13 +0,0 @@
export function getSaveChangesModal() {
return cy.get('.el-overlay').contains('Save changes before leaving?');
}
// this is the button next to 'Save Changes'
export function getCancelSaveChangesButton() {
return cy.get('.btn--cancel');
}
// This is the top right 'x'
export function getCloseSaveChangesButton() {
return cy.get('.el-message-box__headerbtn');
}

View File

@ -1,13 +0,0 @@
/**
* Getters
*/
export const getWorkflowCredentialsModal = () => cy.getByTestId('setup-workflow-credentials-modal');
export const getContinueButton = () => cy.getByTestId('continue-button');
/**
* Actions
*/
export const closeModalFromContinueButton = () => getContinueButton().click();

View File

@ -1,347 +0,0 @@
/**
* Getters
*/
import { getVisiblePopper, getVisibleSelect } from '../utils/popper';
export function getNdvContainer() {
return cy.getByTestId('ndv');
}
export function getCredentialSelect(eq = 0) {
return cy.getByTestId('node-credentials-select').eq(eq);
}
export function getCreateNewCredentialOption() {
return cy.getByTestId('node-credentials-select-item-new');
}
export function getBackToCanvasButton() {
return cy.getByTestId('back-to-canvas');
}
export function getExecuteNodeButton() {
return cy.getByTestId('node-execute-button');
}
export function getParameterInputByName(name: string) {
return cy.getByTestId(`parameter-input-${name}`);
}
export function getInputPanel() {
return cy.getByTestId('ndv-input-panel');
}
export function getInputSelect() {
return cy.getByTestId('ndv-input-select').find('input');
}
export function getInputLinkRun() {
return getInputPanel().findChildByTestId('link-run');
}
export function getMainPanel() {
return cy.getByTestId('node-parameters');
}
export function getOutputPanel() {
return cy.getByTestId('output-panel');
}
export function getFixedCollection(collectionName: string) {
return cy.getByTestId(`fixed-collection-${collectionName}`);
}
export function getResourceLocator(paramName: string) {
return cy.getByTestId(`resource-locator-${paramName}`);
}
export function getResourceLocatorInput(paramName: string) {
return getResourceLocator(paramName).find('[data-test-id="rlc-input-container"]');
}
export function getInputPanelDataContainer() {
return getInputPanel().findChildByTestId('ndv-data-container');
}
export function getInputTableRows() {
return getInputPanelDataContainer().find('table tr');
}
export function getInputTbodyCell(row: number, col: number) {
return getInputTableRows().eq(row).find('td').eq(col);
}
export function getInputRunSelector() {
return cy.get('[data-test-id="ndv-input-panel"] [data-test-id="run-selector"]');
}
export function getInputPanelItemsCount() {
return getInputPanel().getByTestId('ndv-items-count');
}
export function getOutputPanelDataContainer() {
return getOutputPanel().findChildByTestId('ndv-data-container');
}
export function getOutputTableRows() {
return getOutputPanelDataContainer().find('table tr');
}
export function getOutputTableRow(row: number) {
return getOutputTableRows().eq(row);
}
export function getOutputTableHeaders() {
return getOutputPanelDataContainer().find('table thead th');
}
export function getOutputTableHeaderByText(text: string) {
return getOutputTableHeaders().contains(text);
}
export function getOutputTbodyCell(row: number, col: number) {
return getOutputTableRows().eq(row).find('td').eq(col);
}
export function getOutputRunSelector() {
return cy.get('[data-test-id="output-panel"] [data-test-id="run-selector"]');
}
export function getOutputRunSelectorInput() {
return getOutputRunSelector().find('input');
}
export function getOutputPanelTable() {
return getOutputPanelDataContainer().get('table');
}
export function getRunDataInfoCallout() {
return cy.getByTestId('run-data-callout');
}
export function getOutputPanelItemsCount() {
return getOutputPanel().getByTestId('ndv-items-count');
}
export function getOutputPanelRelatedExecutionLink() {
return getOutputPanel().getByTestId('related-execution-link');
}
export function getNodeOutputHint() {
return cy.getByTestId('ndv-output-run-node-hint');
}
export function getWorkflowCards() {
return cy.getByTestId('resources-list-item-workflow');
}
export function getWorkflowCard(workflowName: string) {
return getWorkflowCards()
.contains(workflowName)
.parents('[data-test-id="resources-list-item-workflow"]');
}
export function getWorkflowCardContent(workflowName: string) {
return getWorkflowCard(workflowName).findChildByTestId('card-content');
}
export function getNodeRunInfoStale() {
return cy.getByTestId('node-run-info-stale');
}
export function getNodeOutputErrorMessage() {
return getOutputPanel().findChildByTestId('node-error-message');
}
export function getParameterExpressionPreviewValue() {
return cy.getByTestId('parameter-expression-preview-value');
}
/**
* Actions
*/
export function openCredentialSelect(eq = 0) {
getCredentialSelect(eq).click();
}
export function setCredentialByName(name: string) {
openCredentialSelect();
getCredentialSelect().contains(name).click();
}
export function clickCreateNewCredential() {
openCredentialSelect();
getCreateNewCredentialOption().click({ force: true });
}
export function clickGetBackToCanvas() {
getBackToCanvasButton().click();
}
export function clickExecuteNode() {
getExecuteNodeButton().click();
}
export function clickResourceLocatorInput(paramName: string) {
getResourceLocatorInput(paramName).click();
}
export function setParameterInputByName(name: string, value: string) {
getParameterInputByName(name).clear().type(value);
}
export function checkParameterCheckboxInputByName(name: string) {
getParameterInputByName(name).find('input[type="checkbox"]').check({ force: true });
}
export function uncheckParameterCheckboxInputByName(name: string) {
getParameterInputByName(name).find('input[type="checkbox"]').uncheck({ force: true });
}
export function setParameterSelectByContent(name: string, content: string) {
getParameterInputByName(name).realClick();
getVisibleSelect().find('.option-headline').contains(content).click();
}
export function changeOutputRunSelector(runName: string) {
getOutputRunSelector().click();
getVisibleSelect().find('.el-select-dropdown__item').contains(runName).click();
}
export function addItemToFixedCollection(collectionName: string) {
getFixedCollection(collectionName).getByTestId('fixed-collection-add').click();
}
export function typeIntoFixedCollectionItem(collectionName: string, index: number, value: string) {
getFixedCollection(collectionName).within(() =>
cy.getByTestId('parameter-input').eq(index).type(value),
);
}
export function selectResourceLocatorAddResourceItem(
resourceLocator: string,
expectedText: string,
) {
clickResourceLocatorInput(resourceLocator);
// getVisiblePopper().findChildByTestId('rlc-item-add-resource').eq(0).should('exist');
getVisiblePopper()
.findChildByTestId('rlc-item-add-resource')
.eq(0)
.find('span')
.should('contain.text', expectedText)
.click();
}
export function selectResourceLocatorItem(
resourceLocator: string,
index: number,
expectedText: string,
) {
clickResourceLocatorInput(resourceLocator);
getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist');
getVisiblePopper()
.findChildByTestId('rlc-item')
.eq(index)
.find('span')
.should('contain.text', expectedText)
.click();
}
export function clickWorkflowCardContent(workflowName: string) {
getWorkflowCardContent(workflowName).click();
}
export function clickAssignmentCollectionAdd() {
cy.getByTestId('assignment-collection-drop-area').click();
}
export function assertNodeOutputHintExists() {
getNodeOutputHint().should('exist');
}
export function assertNodeOutputErrorMessageExists() {
return getNodeOutputErrorMessage().should('exist');
}
// Note that this only validates the expectedContent is *included* in the output table
export function assertOutputTableContent(expectedContent: unknown[][]) {
for (const [i, row] of expectedContent.entries()) {
for (const [j, value] of row.entries()) {
// + 1 to skip header
getOutputTbodyCell(1 + i, j).should('have.text', value);
}
}
}
export function populateMapperFields(fields: ReadonlyArray<[string, string]>) {
for (const [name, value] of fields) {
getParameterInputByName(name).type(value);
// Click on a parent to dismiss the pop up which hides the field below.
getParameterInputByName(name).parent().parent().parent().parent().click('topLeft');
}
}
/**
* Populate multiValue fixedCollections. Only supports fixedCollections for which all fields can be defined via keyboard typing
*
* @param items - 2D array of items to populate, i.e. [["myField1", "String"], ["myField2", "Number"]]
* @param collectionName - name of the fixedCollection to populate
* @param offset - amount of 'parameter-input's before start, e.g. from a controlling dropdown that makes the fields appear
* @returns
*/
export function populateFixedCollection<T extends readonly string[]>(
items: readonly T[],
collectionName: string,
offset: number = 0,
) {
if (items.length === 0) return;
const n = items[0].length;
for (const [i, params] of items.entries()) {
addItemToFixedCollection(collectionName);
for (const [j, param] of params.entries()) {
getFixedCollection(collectionName)
.getByTestId('parameter-input')
.eq(offset + i * n + j)
.type(`${param}{downArrow}{enter}`);
}
}
}
export function assertInlineExpressionValid() {
cy.getByTestId('inline-expression-editor-input').find('.cm-valid-resolvable').should('exist');
}
export function hoverInputItemByText(text: string) {
return getInputPanelDataContainer().contains(text).realHover();
}
export function verifyInputHoverState(expectedText: string) {
getInputPanelDataContainer()
.find('[data-test-id="hovering-item"]')
.should('be.visible')
.should('have.text', expectedText);
}
export function verifyOutputHoverState(expectedText: string) {
getOutputPanelDataContainer()
.find('[data-test-id="hovering-item"]')
.should('be.visible')
.should('have.text', expectedText);
}
export function resetHoverState() {
getBackToCanvasButton().realHover();
}
export function setInputDisplayMode(mode: 'Schema' | 'Table' | 'JSON' | 'Binary') {
getInputPanel().findChildByTestId('ndv-run-data-display-mode').contains(mode).click();
}
export function toggleInputRunLinking() {
getInputLinkRun().click();
}

View File

@ -1,31 +0,0 @@
// Getters
export const nodeCreatorPlusButton = () => cy.getByTestId('node-creator-plus-button');
export const canvasAddButton = () => cy.getByTestId('canvas-add-button');
export const searchBar = () => cy.getByTestId('search-bar');
export const getCategoryItem = (label: string) => cy.get(`[data-keyboard-nav-id="${label}"]`);
export const getCreatorItem = (label: string) =>
getCreatorItems().contains(label).parents('[data-test-id="item-iterator-item"]');
export const getNthCreatorItem = (n: number) => getCreatorItems().eq(n);
export const nodeCreator = () => cy.getByTestId('node-creator');
export const nodeCreatorTabs = () => cy.getByTestId('node-creator-type-selector');
export const selectedTab = () => nodeCreatorTabs().find('.is-active');
export const categorizedItems = () => cy.getByTestId('categorized-items');
export const getCreatorItems = () => cy.getByTestId('item-iterator-item');
export const categoryItem = () => cy.getByTestId('node-creator-category-item');
export const communityNodeTooltip = () => cy.getByTestId('node-item-community-tooltip');
export const noResults = () => cy.getByTestId('node-creator-no-results');
export const nodeItemName = () => cy.getByTestId('node-creator-item-name');
export const nodeItemDescription = () => cy.getByTestId('node-creator-item-description');
export const activeSubcategory = () => cy.getByTestId('nodes-list-header');
export const expandedCategories = () =>
getCreatorItems().find('>div').filter('.active').invoke('text');
// Actions
export const openNodeCreator = () => {
nodeCreatorPlusButton().click();
nodeCreator().should('be.visible');
};
export const selectNode = (displayName: string) => {
getCreatorItem(displayName).click();
};

View File

@ -1,95 +0,0 @@
import { CredentialsModal, WorkflowPage } from '../pages';
import { getVisibleSelect } from '../utils';
const workflowPage = new WorkflowPage();
const credentialsModal = new CredentialsModal();
export const getHomeButton = () => cy.getByTestId('project-home-menu-item');
export const getPersonalProjectsButton = () => cy.getByTestId('project-personal-menu-item');
export const getMenuItems = () => cy.getByTestId('project-menu-item');
export const getAddProjectButton = () => {
cy.getByTestId('universal-add').should('be.visible').click();
cy.getByTestId('universal-add')
.find('.el-sub-menu__title')
.as('menuitem')
.should('have.attr', 'aria-describedby');
cy.get('@menuitem')
.invoke('attr', 'aria-describedby')
.then((el) => cy.get(`[id="${el}"]`))
.as('submenu');
cy.get('@submenu').within((submenu) =>
cy
.wrap(submenu)
.getByTestId('navigation-menu-item')
.should('be.visible')
.filter(':contains("Project")')
.as('button'),
);
return cy.get('@button');
};
export const getAddFirstProjectButton = () => cy.getByTestId('add-first-project-button');
export const getIconPickerButton = () => cy.getByTestId('icon-picker-button');
export const getIconPickerTab = (tab: string) => cy.getByTestId('icon-picker-tabs').contains(tab);
export const getIconPickerIcons = () => cy.getByTestId('icon-picker-icon');
export const getIconPickerEmojis = () => cy.getByTestId('icon-picker-emoji');
// export const getAddProjectButton = () =>
// cy.getByTestId('universal-add').should('contain', 'Add project').should('be.visible');
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
export const getProjectTabExecutions = () => getProjectTabs().filter('a[href$="/executions"]');
export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]');
export const getProjectSettingsNameInput = () =>
cy.getByTestId('project-settings-name-input').find('input');
export const getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button');
export const getProjectSettingsCancelButton = () =>
cy.getByTestId('project-settings-cancel-button');
export const getProjectSettingsDeleteButton = () =>
cy.getByTestId('project-settings-delete-button');
export const getProjectMembersSelect = () => cy.getByTestId('project-members-select');
export const addProjectMember = (email: string, role?: string) => {
getProjectMembersSelect().click();
getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click();
if (role) {
cy.getByTestId(`user-list-item-${email}`)
.find('[data-test-id="projects-settings-user-role-select"]')
.click();
getVisibleSelect().find('li').contains(role).click();
}
};
export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal');
export const getProjectMoveSelect = () => cy.getByTestId('project-move-resource-modal-select');
export const getProjectSharingSelect = () => cy.getByTestId('project-sharing-select');
export const getMoveToFolderSelect = () => cy.getByTestId('move-to-folder-dropdown');
export function createProject(name: string) {
getAddProjectButton().click();
getProjectSettingsNameInput().should('be.visible').clear().type(name);
getProjectSettingsSaveButton().click();
}
export function createWorkflow(fixtureKey: string, name: string) {
workflowPage.getters.workflowImportInput().selectFile(`fixtures/${fixtureKey}`, { force: true });
workflowPage.actions.setWorkflowName(name);
workflowPage.getters.saveButton().should('contain', 'Saved');
workflowPage.actions.zoomToFit();
}
export function createCredential(name: string, closeModal = true) {
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName(name);
credentialsModal.actions.save();
if (closeModal) {
credentialsModal.actions.close();
}
}

View File

@ -1,14 +0,0 @@
/**
* Getters
*/
export const getFormStep = () => cy.getByTestId('setup-credentials-form-step');
export const getStepHeading = ($el: JQuery<HTMLElement>) =>
cy.wrap($el).findChildByTestId('credential-step-heading');
export const getStepDescription = ($el: JQuery<HTMLElement>) =>
cy.wrap($el).findChildByTestId('credential-step-description');
export const getCreateAppCredentialsButton = (appName: string) =>
cy.get(`button:contains("Create new ${appName} credential")`);

View File

@ -1,5 +0,0 @@
/**
* Getters
*/
export const getSetupWorkflowCredentialsButton = () => cy.get('button:contains("Set up template")');

View File

@ -1,7 +0,0 @@
export function expandSidebar() {
cy.get('#collapse-change-button').then(($button) => {
if ($button.find('svg[data-icon="chevron-right"]').length > 0) {
cy.get('#collapse-change-button').click();
}
});
}

View File

@ -1,40 +0,0 @@
import { MainSidebar } from '../pages/sidebar/main-sidebar';
const mainSidebar = new MainSidebar();
/**
* Getters
*/
export function getVersionUpdatesPanelOpenButton() {
return cy.getByTestId('version-update-next-versions-link');
}
export function getVersionUpdatesPanel() {
return cy.getByTestId('version-updates-panel');
}
export function getVersionUpdatesPanelCloseButton() {
return getVersionUpdatesPanel().get('.el-drawer__close-btn').first();
}
export function getVersionCard() {
return cy.getByTestId('version-card');
}
/**
* Actions
*/
export function openWhatsNewMenu() {
mainSidebar.getters.whatsNew().should('be.visible');
mainSidebar.getters.whatsNew().click();
}
export function openVersionUpdatesPanel() {
getVersionUpdatesPanelOpenButton().should('be.visible').click();
}
export function closeVersionUpdatesPanel() {
getVersionUpdatesPanelCloseButton().click();
}

View File

@ -1,83 +0,0 @@
import { BACKEND_BASE_URL } from '../constants';
import { NDV, WorkflowPage } from '../pages';
import { getVisibleSelect } from '../utils';
export const waitForWebhook = 500;
export interface SimpleWebhookCallOptions {
method: string;
webhookPath: string;
responseCode?: number;
respondWith?: string;
executeNow?: boolean;
responseData?: string;
authentication?: string;
}
const workflowPage = new WorkflowPage();
const ndv = new NDV();
export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
const {
authentication,
method,
webhookPath,
responseCode,
respondWith,
responseData,
executeNow = true,
} = options;
workflowPage.actions.addInitialNodeToCanvas('Webhook');
workflowPage.actions.openNode('Webhook');
cy.getByTestId('parameter-input-httpMethod').click();
getVisibleSelect().find('.option-headline').contains(method).click();
cy.getByTestId('parameter-input-path')
.find('.parameter-input')
.find('input')
.clear()
.type(webhookPath)
.wait(300);
if (authentication) {
cy.getByTestId('parameter-input-authentication').click();
getVisibleSelect().find('.option-headline').contains(authentication).click();
}
if (responseCode) {
cy.get('.param-options').click();
// wait for selector debounce
getVisibleSelect().contains('Response Code').click().wait(300);
cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
getVisibleSelect().contains('201').click();
}
if (respondWith) {
cy.getByTestId('parameter-input-responseMode').click();
getVisibleSelect().find('.option-headline').contains(respondWith).click();
}
if (responseData) {
cy.getByTestId('parameter-input-responseData').click();
getVisibleSelect().find('.option-headline').contains(responseData).click();
}
const callEndpoint = (fn: (response: Cypress.Response<unknown>) => void) => {
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then(fn);
};
if (executeNow) {
ndv.actions.execute();
cy.wait(waitForWebhook);
callEndpoint((response) => {
expect(response.status).to.eq(200);
ndv.getters.outputPanel().contains('headers');
});
}
return {
callEndpoint,
};
};

View File

@ -1,411 +0,0 @@
import { getManualChatModal } from './modals/chat-modal';
import { clickGetBackToCanvas, getParameterInputByName } from './ndv';
import { META_KEY, ROUTES } from '../constants';
import type { OpenContextMenuOptions } from '../types';
/**
* Types
*/
export type EndpointType =
| 'main'
| 'ai_chain'
| 'ai_document'
| 'ai_embedding'
| 'ai_languageModel'
| 'ai_memory'
| 'ai_outputParser'
| 'ai_tool'
| 'ai_retriever'
| 'ai_textSplitter'
| 'ai_vectorRetriever'
| 'ai_vectorStore';
/**
* Getters
*/
export function getCanvas() {
return cy.getByTestId('canvas');
}
export function getCanvasPane() {
return getCanvas().find('.vue-flow__pane');
}
export function getContextMenu() {
return cy.getByTestId('context-menu').find('.el-dropdown-menu');
}
export function getContextMenuAction(action: string) {
return cy.getByTestId(`context-menu-item-${action}`);
}
export function getInputPlusHandle(nodeName: string) {
return cy.get(
`[data-test-id="canvas-node-input-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
);
}
export function getInputPlusHandleByType(nodeName: string, endpointType: EndpointType) {
return cy.get(
`[data-test-id="canvas-node-input-handle"][data-connection-type="${endpointType}"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
);
}
export function getOutputHandle(nodeName: string) {
return cy.get(`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"]`);
}
export function getOutputPlusHandle(nodeName: string) {
return cy.get(
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
);
}
export function getOutputPlusHandleByType(nodeName: string, endpointType: EndpointType) {
return cy.get(
`[data-test-id="canvas-node-output-handle"][data-connection-type="${endpointType}"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
);
}
export function getNodeCreatorItems() {
return cy.getByTestId('item-iterator-item');
}
export function getExecuteWorkflowButton(triggerNodeName?: string) {
return cy.getByTestId(`execute-workflow-button${triggerNodeName ? `-${triggerNodeName}` : ''}`);
}
export function getManualChatButton() {
return cy.getByTestId('workflow-chat-button');
}
export function getNodes() {
return cy.getByTestId('canvas-node');
}
export function getNodeByName(name: string) {
return cy.getByTestId('canvas-node').filter(`[data-node-name="${name}"]`).eq(0);
}
export function getNodesWithSpinner() {
return cy
.getByTestId('canvas-node')
.filter((_, el) => Cypress.$(el).find('[data-icon=refresh-cw]').length > 0);
}
export function getWaitingNodes() {
return cy
.getByTestId('canvas-node')
.filter((_, el) => Cypress.$(el).find('[data-icon=clock]').length > 0);
}
export function getNodeRenderedTypeByName(name: string) {
return getNodeByName(name).find('[data-canvas-node-render-type]');
}
export function getWorkflowHistoryCloseButton() {
return cy.getByTestId('workflow-history-close-button');
}
export function disableNode(name: string) {
const target = getNodeByName(name);
target.trigger('contextmenu');
cy.getByTestId('context-menu-item-toggle_activation').click();
}
export function getConnectionBySourceAndTarget(source: string, target: string) {
return cy
.getByTestId('edge')
.filter(`[data-source-node-name="${source}"][data-target-node-name="${target}"]`)
.eq(0);
}
export function getConnectionLabelBySourceAndTarget(source: string, target: string) {
return cy
.getByTestId('edge-label')
.filter(`[data-source-node-name="${source}"][data-target-node-name="${target}"]`);
}
export function getNodeCreatorSearchBar() {
return cy.getByTestId('node-creator-search-bar');
}
export function getNodeCreatorPlusButton() {
return cy.getByTestId('node-creator-plus-button');
}
export function getCanvasNodes() {
return cy.getByTestId('canvas-node');
}
export function getCanvasNodeByName(nodeName: string) {
return getCanvasNodes().filter(`:contains(${nodeName})`);
}
export function getSaveButton() {
return cy.getByTestId('workflow-save-button');
}
export function getZoomToFitButton() {
return cy.getByTestId('zoom-to-fit');
}
export function getNodeIssuesByName(nodeName: string) {
return getCanvasNodes()
.filter(`:contains(${nodeName})`)
.should('have.length.greaterThan', 0)
.findChildByTestId('node-issues');
}
/**
* Actions
*/
export function executeWorkflow() {
cy.get('[data-test-id="execute-workflow-button"]').click();
}
export function waitForSuccessBannerToAppear() {
cy.contains(/(Workflow|Node) executed successfully/, { timeout: 4000 }).should('be.visible');
}
export function executeWorkflowAndWait(waitForSuccessBannerToDisappear = true) {
executeWorkflow();
waitForSuccessBannerToAppear();
if (waitForSuccessBannerToDisappear) {
cy.contains('Workflow executed successfully', { timeout: 10000 }).should('not.exist');
}
}
/**
* @param nodeDisplayName - The name of the node to add to the canvas
* @param plusButtonClick - Whether to click the plus button to open the node creator
* @param preventNdvClose - Whether to prevent the Ndv from closing
* @param action - The action to select in the node creator
* @param useExactMatch - Whether to use an exact match for the node name will use selector instead of enter key
*/
export function addNodeToCanvas(
nodeDisplayName: string,
plusButtonClick = true,
preventNdvClose?: boolean,
action?: string,
useExactMatch = false,
) {
if (plusButtonClick) {
getNodeCreatorPlusButton().click();
}
getNodeCreatorSearchBar().type(nodeDisplayName);
if (useExactMatch) {
cy.getByTestId('node-creator-item-name').contains(nodeDisplayName).click();
} else {
getNodeCreatorSearchBar().type('{enter}');
}
cy.wait(500);
cy.get('body').then((body) => {
if (body.find('[data-test-id=node-creator]').length > 0) {
if (action) {
cy.contains(action).click();
} else {
cy.get('[data-keyboard-nav-type="action"]').eq(0).click();
}
}
});
if (!preventNdvClose) {
cy.get('body').type('{esc}');
}
}
export function navigateToNewWorkflowPage(preventNodeViewUnload = true) {
cy.visit(ROUTES.NEW_WORKFLOW_PAGE);
cy.getByTestId('node-creator-plus-button').should('be.visible');
cy.waitForLoad();
cy.window().then((win) => {
win.preventNodeViewBeforeUnload = preventNodeViewUnload;
});
}
function connectNodeToParent(
nodeName: string,
endpointType: EndpointType,
parentNodeName: string,
exactMatch = false,
) {
getInputPlusHandleByType(parentNodeName, endpointType).click({ force: true });
if (exactMatch) {
getNodeCreatorItems()
.contains(new RegExp('^' + nodeName + '$', 'g'))
.click();
} else {
getNodeCreatorItems().contains(nodeName).click();
}
}
export function addSupplementalNodeToParent(
nodeName: string,
endpointType: EndpointType,
parentNodeName: string,
exactMatch = false,
) {
connectNodeToParent(nodeName, endpointType, parentNodeName, exactMatch);
if (endpointType === 'main') {
getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist');
} else {
getConnectionBySourceAndTarget(nodeName, parentNodeName).should('exist');
}
}
export function addLanguageModelNodeToParent(
nodeName: string,
parentNodeName: string,
exactMatch = false,
) {
addSupplementalNodeToParent(nodeName, 'ai_languageModel', parentNodeName, exactMatch);
}
export function addMemoryNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_memory', parentNodeName);
}
export function addToolNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_tool', parentNodeName);
}
export function addVectorStoreToolToParent(nodeName: string, parentNodeName: string) {
connectNodeToParent(nodeName, 'ai_tool', parentNodeName, false);
getParameterInputByName('mode')
.find('input')
.should('have.value', 'Retrieve Documents (As Tool for AI Agent)');
clickGetBackToCanvas();
getConnectionBySourceAndTarget(nodeName, parentNodeName).should('exist');
}
export function addOutputParserNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_outputParser', parentNodeName);
}
export function addVectorStoreNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_vectorStore', parentNodeName);
}
export function addRetrieverNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_retriever', parentNodeName);
}
export function clickExecuteWorkflowButton(triggerNodeName?: string) {
getExecuteWorkflowButton(triggerNodeName).click();
}
export function clickManualChatButton() {
getManualChatButton().click();
getManualChatModal().should('be.visible');
}
export function openNode(nodeName: string) {
getNodeByName(nodeName).dblclick();
}
export function saveWorkflowOnButtonClick() {
cy.intercept('POST', '/rest/workflows').as('createWorkflow');
getSaveButton().should('contain', 'Save');
getSaveButton().click();
getSaveButton().should('contain', 'Saved');
cy.url().should('not.have.string', '/new');
}
export function pasteWorkflow(workflow: object) {
cy.get('body').paste(JSON.stringify(workflow));
}
export function clickZoomToFit() {
getZoomToFitButton().click();
}
export function deleteNode(name: string) {
getCanvasNodeByName(name).first().click();
cy.get('body').type('{del}');
}
export function openContextMenu(
nodeName?: string,
{ method = 'right-click', anchor = 'center' }: OpenContextMenuOptions = {},
) {
let target;
if (nodeName) {
target =
method === 'right-click' ? getNodeRenderedTypeByName(nodeName) : getNodeByName(nodeName);
} else {
target = getCanvasPane();
}
if (method === 'right-click') {
target.rightclick(nodeName ? anchor : 'topLeft', { force: true });
} else {
target.realHover();
target.find('[data-test-id="overflow-node-button"]').click({ force: true });
}
getContextMenu().should('be.visible');
}
export function clickContextMenuAction(action: string) {
getContextMenuAction(action).click({ force: true });
}
export function openExecutions() {
cy.getByTestId('radio-button-executions').click();
}
export function clickClearExecutionDataButton() {
cy.getByTestId('clear-execution-data-button').click();
}
/**
* Undo/Redo
*/
export function hitComboShortcut(modifier: string, key: string) {
cy.get('body').wait(100).type(modifier, { delay: 100, release: false }).type(key);
}
export function hitUndo() {
hitComboShortcut(`{${META_KEY}}`, 'z');
}
export function hitRedo() {
cy.get('body').type(`{${META_KEY}+shift+z}`);
}
export function hitSelectAll() {
hitComboShortcut(`{${META_KEY}}`, 'a');
}
export function hitDeleteAllNodes() {
hitSelectAll();
cy.get('body').type('{backspace}');
}
export function hitDisableNodeShortcut() {
cy.get('body').type('d');
}
export function hitCopy() {
hitComboShortcut(`{${META_KEY}}`, 'c');
}
export function hitPinNodeShortcut() {
cy.get('body').type('p');
}
export function hitSaveWorkflow() {
cy.get('body').type(`{${META_KEY}+s}`);
}
export function hitExecuteWorkflow() {
cy.get('body').type(`{${META_KEY}+enter}`);
}
export function hitDuplicateNode() {
cy.get('body').type(`{${META_KEY}+d}`);
}
export function hitAddSticky() {
cy.get('body').type('{shift+S}');
}
export function selectRight() {
cy.get('body').type('{shift+rightArrow}');
}

View File

@ -1,19 +0,0 @@
/**
* Getters
*/
export function getWorkflowsPageUrl() {
return '/home/workflows';
}
export const getCreateWorkflowButton = () => cy.getByTestId('add-resource-workflow');
export const getNewWorkflowCardButton = () => cy.getByTestId('new-workflow-card');
/**
* Actions
*/
export function visitWorkflowsPage() {
cy.visit(getWorkflowsPageUrl());
}

View File

@ -1,78 +0,0 @@
import { randFirstName, randLastName } from '@ngneat/falso';
export const BACKEND_BASE_URL = 'http://localhost:5678';
export const N8N_AUTH_COOKIE = 'n8n-auth';
const DEFAULT_USER_PASSWORD = 'CypressTest123';
export const INSTANCE_OWNER = {
email: 'nathan@n8n.io',
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
};
export const INSTANCE_ADMIN = {
email: 'admin@n8n.io',
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
};
export const INSTANCE_MEMBERS = [
{
email: 'rebecca@n8n.io',
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
},
{
email: 'mustafa@n8n.io',
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
},
];
export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking ‘Execute workflow’';
export const MANUAL_CHAT_TRIGGER_NODE_NAME = 'Chat Trigger';
export const CHAT_TRIGGER_NODE_DISPLAY_NAME = 'When chat message received';
export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
export const CODE_NODE_NAME = 'Code';
export const CODE_NODE_DISPLAY_NAME = 'Code in JavaScript';
export const CODE_NODE_ACTION = CODE_NODE_DISPLAY_NAME;
export const SET_NODE_NAME = 'Set';
export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields';
export const LOOP_OVER_ITEMS_NODE_NAME = 'Loop Over Items';
export const IF_NODE_NAME = 'If';
export const MERGE_NODE_NAME = 'Merge';
export const SWITCH_NODE_NAME = 'Switch';
export const GMAIL_NODE_NAME = 'Gmail';
export const TRELLO_NODE_NAME = 'Trello';
export const NOTION_NODE_NAME = 'Notion';
export const PIPEDRIVE_NODE_NAME = 'Pipedrive';
export const HTTP_REQUEST_NODE_NAME = 'HTTP Request';
export const AGENT_NODE_NAME = 'AI Agent';
export const BASIC_LLM_CHAIN_NODE_NAME = 'Basic LLM Chain';
export const AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME = 'Simple Memory';
export const AI_TOOL_CALCULATOR_NODE_NAME = 'Calculator';
export const AI_TOOL_CODE_NODE_NAME = 'Code Tool';
export const AI_TOOL_WIKIPEDIA_NODE_NAME = 'Wikipedia';
export const AI_TOOL_HTTP_NODE_NAME = 'HTTP Request Tool';
export const AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME = 'OpenAI Chat Model';
export const AI_MEMORY_POSTGRES_NODE_NAME = 'Postgres Chat Memory';
export const AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME = 'Auto-fixing Output Parser';
export const WEBHOOK_NODE_NAME = 'Webhook';
export const EXECUTE_WORKFLOW_NODE_NAME = 'Execute Workflow';
export const META_KEY = Cypress.platform === 'darwin' ? 'meta' : 'ctrl';
export const NEW_GOOGLE_ACCOUNT_NAME = 'Gmail account';
export const NEW_TRELLO_ACCOUNT_NAME = 'Trello account';
export const NEW_NOTION_ACCOUNT_NAME = 'Notion account';
export const NEW_QUERY_AUTH_ACCOUNT_NAME = 'Query Auth account';
export const ROUTES = {
NEW_WORKFLOW_PAGE: '/workflow/new',
};

View File

@ -1,36 +0,0 @@
const { defineConfig } = require('cypress');
const BASE_URL = 'http://localhost:5678';
module.exports = defineConfig({
retries: {
openMode: 0,
runMode: 2,
},
defaultCommandTimeout: 10000,
requestTimeout: 12000,
numTestsKeptInMemory: 2,
experimentalMemoryManagement: true,
e2e: {
baseUrl: BASE_URL,
viewportWidth: 1536,
viewportHeight: 960,
video: true,
screenshotOnRunFailure: true,
experimentalInteractiveRunEvents: true,
specPattern: 'e2e/**/*.ts',
supportFile: 'support/e2e.ts',
fixturesFolder: 'fixtures',
downloadsFolder: 'downloads',
screenshotsFolder: 'screenshots',
videosFolder: 'videos',
setupNodeEvents(on, config) {
require('@cypress/grep/src/plugin')(config);
return config;
},
},
reporter: 'mocha-junit-reporter',
reporterOptions: {
mochaFile: 'test-results-[hash].xml',
},
});

View File

@ -1,314 +0,0 @@
import type { RouteHandler } from 'cypress/types/net-stubbing';
import executionOutOfMemoryServerResponse from '../../fixtures/responses/execution-out-of-memory-server-response.json';
import { WorkflowPage } from '../../pages';
import { WorkflowExecutionsTab } from '../../pages/workflow-executions-tab';
import { getVisibleSelect } from '../../utils';
const workflowPage = new WorkflowPage();
const executionsTab = new WorkflowExecutionsTab();
const executionsRefreshInterval = 4000;
// Test suite for executions tab
describe('Workflow Executions', () => {
describe('when workflow is saved', () => {
beforeEach(() => {
workflowPage.actions.visit();
cy.createFixtureWorkflow('Test_workflow_4_executions_view.json');
});
it('should render executions tab correctly', () => {
createMockExecutions();
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
executionsTab.getters.executionsList().scrollTo(0, 500).wait(0);
executionsTab.getters.executionListItems().should('have.length', 30);
executionsTab.getters.successfulExecutionListItems().should('have.length', 28);
executionsTab.getters.failedExecutionListItems().should('have.length', 2);
executionsTab.getters
.executionListItems()
.first()
.invoke('attr', 'class')
.should('match', /_active_/);
});
it('should not redirect back to execution tab when request is not done before leaving the page', () => {
cy.intercept('GET', '/rest/executions?filter=*');
cy.intercept('GET', '/rest/executions/active?filter=*');
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(1000);
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
});
it('should not redirect back to execution tab when slow request is not done before leaving the page', () => {
const throttleResponse: RouteHandler = async (req) => {
return await new Promise((resolve) => {
setTimeout(() => resolve(req.continue()), 2000);
});
};
cy.intercept('GET', '/rest/executions?filter=*', throttleResponse);
cy.intercept('GET', '/rest/executions/active?filter=*', throttleResponse);
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
});
it('should error toast when server error message returned without stack trace', () => {
executionsTab.actions.createManualExecutions(1);
const message = 'Workflow did not finish, possible out-of-memory issue';
cy.intercept('GET', '/rest/executions/*', {
statusCode: 200,
body: executionOutOfMemoryServerResponse,
}).as('getExecution');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecution']);
executionsTab.getters
.workflowExecutionPreviewIframe()
.should('be.visible')
.its('0.contentDocument.body') // Access the body of the iframe document
.should('not.be.empty') // Ensure the body is not empty
.then(cy.wrap)
.find('.el-notification:has(.el-notification--error)')
.should('be.visible')
.filter(`:contains("${message}")`)
.should('be.visible');
});
it('should show workflow data in executions tab after hard reload and modify name and tags', () => {
executionsTab.actions.switchToExecutionsTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
workflowPage.getters.workflowTags().click();
getVisibleSelect().find('li:contains("Manage tags")').click();
cy.get('button:contains("Add new")').click();
cy.getByTestId('tags-table').find('input').type('nutag').type('{enter}');
cy.get('button:contains("Done")').click();
cy.reload();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.workflowTags().click();
workflowPage.getters.tagsInDropdown().first().should('have.text', 'nutag').click();
workflowPage.getters.tagPills().should('have.length', 3);
let newWorkflowName = 'Renamed workflow';
workflowPage.actions.renameWorkflow(newWorkflowName);
workflowPage.getters.isWorkflowSaved();
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToEditorTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 3);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToExecutionsTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 3);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToEditorTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 3);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
newWorkflowName = 'New workflow';
workflowPage.actions.renameWorkflow(newWorkflowName);
workflowPage.getters.isWorkflowSaved();
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
workflowPage.getters.workflowTags().click();
workflowPage.getters.tagsDropdown().find('.el-tag__close').first().click();
cy.get('body').click(0, 0);
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
executionsTab.actions.switchToExecutionsTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToEditorTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
});
// This should be a component test. Abstracting this away into to ensure our lists work.
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('should load items and auto scroll after filter change', () => {
createMockExecutions();
createMockExecutions();
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
executionsTab.getters.executionsList().scrollTo(0, 500).wait(0);
executionsTab.getters.executionListItems().eq(10).click();
cy.getByTestId('executions-filter-button').click();
cy.getByTestId('executions-filter-status-select').should('be.visible').click();
getVisibleSelect().find('li:contains("Error")').click();
executionsTab.getters.executionListItems().should('have.length', 5);
executionsTab.getters.successfulExecutionListItems().should('have.length', 1);
executionsTab.getters.failedExecutionListItems().should('have.length', 4);
cy.getByTestId('executions-filter-button').click();
cy.getByTestId('executions-filter-status-select').should('be.visible').click();
getVisibleSelect().find('li:contains("Success")').click();
// check if the list is scrolled
executionsTab.getters.executionListItems().eq(10).should('be.visible');
executionsTab.getters.executionsList().then(($el) => {
const { scrollTop, scrollHeight, clientHeight } = $el[0];
expect(scrollTop).to.be.greaterThan(0);
expect(scrollTop + clientHeight).to.be.lessThan(scrollHeight);
// scroll to the bottom
$el[0].scrollTo(0, scrollHeight);
executionsTab.getters.executionListItems().should('have.length', 18);
executionsTab.getters.successfulExecutionListItems().should('have.length', 18);
executionsTab.getters.failedExecutionListItems().should('have.length', 0);
});
cy.getByTestId('executions-filter-button').click();
cy.getByTestId('executions-filter-reset-button').should('be.visible').click();
executionsTab.getters.executionListItems().eq(11).should('be.visible');
});
it('should redirect back to editor after seeing a couple of execution using browser back button', () => {
createMockExecutions();
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
executionsTab.getters.executionListItems().eq(2).click();
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
executionsTab.getters.executionListItems().eq(4).click();
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
executionsTab.getters.executionListItems().eq(6).click();
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
executionsTab.getters.workflowExecutionPreviewIframe().should('exist');
cy.go('back');
cy.url().should('not.include', '/executions');
cy.url().should('include', '/workflow/');
workflowPage.getters.nodeViewRoot().should('be.visible');
});
});
describe('when new workflow is not saved', () => {
beforeEach(() => {
workflowPage.actions.visit();
});
it('should open executions tab', () => {
executionsTab.actions.switchToExecutionsTab();
executionsTab.getters.executionsSidebar().should('be.visible');
executionsTab.getters.executionsEmptyList().should('be.visible');
cy.getByTestId('workflow-execution-no-trigger-content').should('be.visible');
cy.get('button:contains("Add first step")').should('be.visible').click();
cy.getByTestId('node-creator-item-name')
.should('be.visible')
.filter(':contains("Trigger manually")')
.click();
executionsTab.actions.switchToExecutionsTab();
executionsTab.getters.executionsSidebar().should('be.visible');
executionsTab.getters.executionsEmptyList().should('be.visible');
cy.getByTestId('workflow-execution-no-content').should('be.visible');
workflowPage.getters.saveButton().find('button').should('be.enabled').click();
workflowPage.getters.isWorkflowSaved();
workflowPage.getters.nodeViewRoot().should('be.visible');
});
});
});
const createMockExecutions = () => {
executionsTab.actions.createManualExecutions(15);
// This wait is added to allow time for the notifications to expire
cy.wait(2000);
// Make some failed executions by enabling Code node with syntax error
executionsTab.actions.toggleNodeEnabled('Error');
workflowPage.getters.disabledNodes().should('have.length', 0);
executionsTab.actions.createManualExecutions(2);
// This wait is added to allow time for the notifications to expire
cy.wait(2000);
// Then add some more successful ones
executionsTab.actions.toggleNodeEnabled('Error');
workflowPage.getters.disabledNodes().should('have.length', 1);
executionsTab.actions.createManualExecutions(15);
};
const checkMainHeaderELements = () => {
workflowPage.getters.workflowNameInputContainer().should('be.visible');
workflowPage.getters.workflowTagsContainer().should('be.visible');
workflowPage.getters.workflowMenu().should('be.visible');
workflowPage.getters.saveButton().should('be.visible');
};

View File

@ -1,486 +0,0 @@
import {
CODE_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
NOTION_NODE_NAME,
} from '../../constants';
import { WorkflowExecutionsTab } from '../../pages';
import { errorToast, successToast } from '../../pages/notifications';
import { WorkflowPage as WorkflowPageClass } from '../../pages/workflow';
import { WorkflowsPage as WorkflowsPageClass } from '../../pages/workflows';
import { getVisibleSelect } from '../../utils';
const NEW_WORKFLOW_NAME = 'Something else';
const DUPLICATE_WORKFLOW_NAME = 'Duplicated workflow';
const DUPLICATE_WORKFLOW_TAG = 'Duplicate';
const WorkflowPage = new WorkflowPageClass();
const WorkflowPages = new WorkflowsPageClass();
const executionsTab = new WorkflowExecutionsTab();
describe('Workflow Actions', () => {
beforeEach(() => {
WorkflowPage.actions.visit();
});
it('should be able to save on button click', () => {
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.isWorkflowSaved();
});
it('should save workflow on keyboard shortcut', () => {
WorkflowPage.actions.saveWorkflowUsingKeyboardShortcut();
WorkflowPage.getters.isWorkflowSaved();
});
it('should not save already saved workflow', () => {
cy.intercept('PATCH', '/rest/workflows/*').as('saveWorkflow');
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.wait('@saveWorkflow');
WorkflowPage.getters.isWorkflowSaved();
// Try to save a few times
WorkflowPage.actions.saveWorkflowUsingKeyboardShortcut();
WorkflowPage.actions.saveWorkflowUsingKeyboardShortcut();
// Should be saved only once
cy.get('@saveWorkflow.all').should('have.length', 1);
});
it('should not be able to activate unsaved workflow', () => {
WorkflowPage.getters.activatorSwitch().find('input').first().should('be.disabled');
});
it('should not be able to activate workflow without trigger node', () => {
// Manual trigger is not enough to activate the workflow
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.activatorSwitch().find('input').first().should('be.disabled');
});
it('should be able to activate workflow', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.actions.activateWorkflow();
WorkflowPage.getters.isWorkflowActivated();
});
it('should not be be able to activate workflow when nodes have errors', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
successToast().should('exist');
WorkflowPage.actions.clickWorkflowActivator();
errorToast().should('exist');
});
it('should be be able to activate workflow when nodes with errors are disabled', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
successToast().should('exist');
// First, try to activate the workflow with errors
WorkflowPage.actions.clickWorkflowActivator();
errorToast().should('exist');
// Now, disable the node with errors
WorkflowPage.getters.canvasNodes().last().click();
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.actions.activateWorkflow();
WorkflowPage.getters.isWorkflowActivated();
});
it('should save new workflow after renaming', () => {
WorkflowPage.actions.renameWorkflow(NEW_WORKFLOW_NAME);
WorkflowPage.getters.isWorkflowSaved();
});
it('should rename workflow', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.actions.renameWorkflow(NEW_WORKFLOW_NAME);
WorkflowPage.getters.isWorkflowSaved();
WorkflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', NEW_WORKFLOW_NAME);
});
it('should not save workflow if canvas is loading', () => {
let interceptCalledCount = 0;
// There's no way in Cypress to check if intercept was not called
// so we'll count the number of times it was called
cy.intercept('PATCH', '/rest/workflows/*', () => {
interceptCalledCount++;
}).as('saveWorkflow');
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.intercept(
{
url: '/rest/workflows/*',
method: 'GET',
middleware: true,
},
(req) => {
// Delay the response to give time for the save to be triggered
req.on('response', async (res) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
res.send();
});
},
);
cy.reload();
cy.get('.el-loading-mask').should('exist');
WorkflowPage.actions.hitSaveWorkflow();
WorkflowPage.actions.hitSaveWorkflow();
WorkflowPage.actions.hitSaveWorkflow();
cy.wrap(null).then(() => expect(interceptCalledCount).to.eq(0));
cy.waitForLoad();
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.hitSaveWorkflow();
cy.wait('@saveWorkflow');
cy.wrap(null).then(() => expect(interceptCalledCount).to.eq(1));
});
it('should not save workflow twice when save is in progress', () => {
// This happens when users click save button from workflow name input
// In this case blur on the input saves the workflow and then click on the button saves it again
WorkflowPage.actions.visit();
WorkflowPage.getters
.workflowNameInput()
.invoke('val')
.then((oldName) => {
WorkflowPage.getters.workflowNameInputContainer().click();
WorkflowPage.getters.workflowNameInput().type('{selectall}');
WorkflowPage.getters.workflowNameInput().type('Test');
WorkflowPage.getters.saveButton().click();
WorkflowPage.getters.workflowNameInput().should('have.value', 'Test');
cy.visit(WorkflowPages.url);
// There should be no workflow with the old name (duplicate save)
WorkflowPages.getters.workflowCards().contains(String(oldName)).should('not.exist');
});
});
it('should copy nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
cy.get('#node-creator').should('not.exist');
WorkflowPage.actions.hitSelectAll();
WorkflowPage.actions.hitCopy();
successToast().should('exist');
// Both nodes should be copied
cy.window()
.its('navigator.clipboard')
.then((clip) => clip.readText())
.then((text) => {
const copiedWorkflow = JSON.parse(text);
expect(copiedWorkflow.nodes).to.have.length(2);
});
});
it('should paste nodes (both current and old node versions)', () => {
cy.fixture('Test_workflow-actions_paste-data.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
WorkflowPage.actions.zoomToFit();
WorkflowPage.getters.canvasNodes().should('have.length', 5);
WorkflowPage.getters.nodeConnections().should('have.length', 5);
});
});
it('should allow importing nodes without names', () => {
cy.fixture('Test_workflow-actions_import_nodes_empty_name.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
WorkflowPage.actions.zoomToFit();
WorkflowPage.getters.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 2);
// Check if all nodes have names
WorkflowPage.getters.canvasNodes().each((node) => {
cy.wrap(node).should('have.attr', 'data-node-name');
});
});
});
it('should update workflow settings', () => {
cy.visit(WorkflowPages.url);
cy.intercept('GET', '/rest/workflows', (req) => {
req.on('response', (res) => {
const totalWorkflows = res.body.count ?? 0;
WorkflowPage.actions.visit();
// Open settings dialog
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemSettings().should('be.visible');
WorkflowPage.getters.workflowMenuItemSettings().click();
// Change all settings
// totalWorkflows + 1 (current workflow) + 1 (no workflow option)
WorkflowPage.getters.workflowSettingsErrorWorkflowSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', totalWorkflows + 2);
getVisibleSelect().find('li').last().click({ force: true });
WorkflowPage.getters.workflowSettingsTimezoneSelect().click();
getVisibleSelect().find('li').should('exist');
getVisibleSelect().find('li').eq(1).click({ force: true });
WorkflowPage.getters.workflowSettingsSaveFiledExecutionsSelect().click();
getVisibleSelect().find('li').should('have.length', 3);
getVisibleSelect().find('li').last().click({ force: true });
WorkflowPage.getters.workflowSettingsSaveSuccessExecutionsSelect().click();
getVisibleSelect().find('li').should('have.length', 3);
getVisibleSelect().find('li').last().click({ force: true });
WorkflowPage.getters.workflowSettingsSaveManualExecutionsSelect().click();
getVisibleSelect().find('li').should('have.length', 3);
getVisibleSelect().find('li').last().click({ force: true });
WorkflowPage.getters.workflowSettingsSaveExecutionProgressSelect().click();
getVisibleSelect().find('li').should('have.length', 3);
getVisibleSelect().find('li').last().click({ force: true });
WorkflowPage.getters.workflowSettingsTimeoutWorkflowSwitch().click();
WorkflowPage.getters.workflowSettingsTimeoutForm().find('input').first().type('1');
// Save settings
WorkflowPage.getters.workflowSettingsSaveButton().click();
WorkflowPage.getters.workflowSettingsModal().should('not.exist');
successToast().should('exist');
});
}).as('loadWorkflows');
});
it('should not be able to archive or delete unsaved workflow', () => {
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemDelete().should('not.exist');
WorkflowPage.getters
.workflowMenuItemArchive()
.closest('li')
.should('have.class', 'is-disabled');
});
it('should archive nonactive workflow and then delete it', () => {
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.archivedTag().should('not.exist');
// Archive the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemArchive().click();
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
// Return back to the workflow
cy.go('back');
WorkflowPage.getters.archivedTag().should('be.visible');
WorkflowPage.getters.nodeCreatorPlusButton().should('not.exist');
// Delete the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemDelete().click();
WorkflowPage.actions.acceptConfirmModal();
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
});
it('should archive active workflow and then delete it', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.actions.activateWorkflow();
WorkflowPage.getters.isWorkflowActivated();
WorkflowPage.getters.archivedTag().should('not.exist');
// Archive the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemArchive().click();
WorkflowPage.actions.acceptConfirmModal();
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
// Return back to the workflow
cy.go('back');
WorkflowPage.getters.archivedTag().should('be.visible');
WorkflowPage.getters.nodeCreatorPlusButton().should('not.exist');
WorkflowPage.getters.isWorkflowDeactivated();
// Delete the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemDelete().click();
WorkflowPage.actions.acceptConfirmModal();
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
});
it('should archive nonactive workflow and then unarchive it', () => {
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.archivedTag().should('not.exist');
// Archive the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemArchive().click();
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
// Return back to the workflow
cy.go('back');
WorkflowPage.getters.archivedTag().should('be.visible');
WorkflowPage.getters.nodeCreatorPlusButton().should('not.exist');
// Unarchive the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemUnarchive().click();
successToast().should('exist');
WorkflowPage.getters.archivedTag().should('not.exist');
WorkflowPage.getters.nodeCreatorPlusButton().should('be.visible');
});
it('should deactivate active workflow on archive', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.actions.activateWorkflow();
WorkflowPage.getters.isWorkflowActivated();
// Archive the workflow
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemArchive().click();
WorkflowPage.actions.acceptConfirmModal();
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
// Return back to the workflow
cy.go('back');
WorkflowPage.getters.archivedTag().should('be.visible');
WorkflowPage.getters.isWorkflowDeactivated();
WorkflowPage.getters.activatorSwitch().find('input').first().should('be.disabled');
// Unarchive the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemUnarchive().click();
successToast().should('exist');
WorkflowPage.getters.archivedTag().should('not.exist');
// Activate the workflow again
WorkflowPage.actions.activateWorkflow();
WorkflowPage.getters.isWorkflowActivated();
});
describe('duplicate workflow', () => {
function duplicateWorkflow() {
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemDuplicate().click();
WorkflowPage.getters.duplicateWorkflowModal().should('be.visible');
WorkflowPage.getters.duplicateWorkflowModal().find('input').first().should('be.visible');
WorkflowPage.getters.duplicateWorkflowModal().find('input').first().type('{selectall}');
WorkflowPage.getters
.duplicateWorkflowModal()
.find('input')
.first()
.type(DUPLICATE_WORKFLOW_NAME);
WorkflowPage.getters
.duplicateWorkflowModal()
.find('.el-select__tags input')
.type(DUPLICATE_WORKFLOW_TAG);
WorkflowPage.getters.duplicateWorkflowModal().find('.el-select__tags input').type('{enter}');
WorkflowPage.getters.duplicateWorkflowModal().find('.el-select__tags input').type('{esc}');
WorkflowPage.getters
.duplicateWorkflowModal()
.find('button')
.contains('Duplicate')
.should('be.visible');
WorkflowPage.getters.duplicateWorkflowModal().find('button').contains('Duplicate').click();
errorToast().should('not.exist');
}
beforeEach(() => {
// Stub window.open so new tab is not getting opened
cy.window().then((win) => {
cy.stub(win, 'open').as('open');
});
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
});
it('should duplicate unsaved workflow', () => {
duplicateWorkflow();
});
it('should duplicate saved workflow', () => {
WorkflowPage.actions.saveWorkflowOnButtonClick();
duplicateWorkflow();
});
});
it('should keep endpoint click working when switching between execution and editor tab', () => {
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.canvasNodePlusEndpointByName(EDIT_FIELDS_SET_NODE_NAME).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
cy.get('body').type('{esc}');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
cy.wait(500);
executionsTab.actions.switchToEditorTab();
WorkflowPage.getters.canvasNodePlusEndpointByName(EDIT_FIELDS_SET_NODE_NAME).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
});
it('should run workflow on button click', () => {
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.executeWorkflowButton().click();
successToast().should('contain.text', 'Workflow executed successfully');
});
it('should run workflow using keyboard shortcut', () => {
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.actions.hitExecuteWorkflow();
successToast().should('contain.text', 'Workflow executed successfully');
});
it('should not run empty workflows', () => {
// Clear the canvas
WorkflowPage.actions.hitDeleteAllNodes();
WorkflowPage.getters.canvasNodes().should('have.length', 0);
WorkflowPage.getters.executeWorkflowButton().should('not.exist');
// Keyboard shortcut should not work
WorkflowPage.actions.hitExecuteWorkflow();
successToast().should('not.exist');
});
});
describe('Menu entry Push To Git', () => {
it('should not show up in the menu for members', () => {
cy.signinAsMember(0);
cy.visit(WorkflowPages.url);
WorkflowPage.actions.visit();
WorkflowPage.getters.workflowMenuItemGitPush().should('not.exist');
});
it('should show up for owners', () => {
cy.signinAsOwner();
cy.visit(WorkflowPages.url);
WorkflowPage.actions.visit();
WorkflowPage.getters.workflowMenuItemGitPush().should('exist');
});
});

View File

@ -1,457 +0,0 @@
import {
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
SWITCH_NODE_NAME,
MERGE_NODE_NAME,
CODE_NODE_DISPLAY_NAME,
} from './../../constants';
import {
clickContextMenuAction,
getCanvasNodeByName,
getCanvasNodes,
getConnectionBySourceAndTarget,
getConnectionLabelBySourceAndTarget,
getOutputPlusHandle,
openContextMenu,
} from '../../composables/workflow';
import { NDV, WorkflowExecutionsTab } from '../../pages';
import { clearNotifications, successToast } from '../../pages/notifications';
import { WorkflowPage as WorkflowPageClass } from '../../pages/workflow';
const WorkflowPage = new WorkflowPageClass();
const ExecutionsTab = new WorkflowExecutionsTab();
const NDVDialog = new NDV();
const DEFAULT_ZOOM_FACTOR = 1;
const ZOOM_IN_X1_FACTOR = 1.25; // Zoom in factor after one click
const ZOOM_IN_X2_FACTOR = 1.5625; // Zoom in factor after two clicks
const ZOOM_OUT_X1_FACTOR = 0.8;
const ZOOM_OUT_X2_FACTOR = 0.64;
const RENAME_NODE_NAME = 'Something else';
const RENAME_NODE_NAME2 = 'Something different';
describe('Canvas Node Manipulation and Navigation', () => {
beforeEach(() => {
WorkflowPage.actions.visit();
});
it('should add switch node and test connections', () => {
const desiredOutputs = 4;
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true);
WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, true, true);
for (let i = 0; i < desiredOutputs; i++) {
cy.contains('Add Routing Rule').click();
}
NDVDialog.actions.close();
for (let i = 0; i < desiredOutputs; i++) {
getOutputPlusHandle(SWITCH_NODE_NAME).eq(0).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false);
WorkflowPage.actions.zoomToFit();
}
WorkflowPage.getters.nodeViewBackground().click({ force: true });
getOutputPlusHandle(`${EDIT_FIELDS_SET_NODE_NAME}3`).click();
WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, false);
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
cy.waitForLoad();
// Make sure output-less switch was connected correctly
getConnectionBySourceAndTarget(`${EDIT_FIELDS_SET_NODE_NAME}3`, `${SWITCH_NODE_NAME}1`).should(
'exist',
);
// Make sure all connections are there after reload
for (let i = 0; i < desiredOutputs; i++) {
const setName = `${EDIT_FIELDS_SET_NODE_NAME}${i > 0 ? i : ''}`;
getConnectionBySourceAndTarget(`${SWITCH_NODE_NAME}`, setName).should('exist');
}
});
it('should add merge node and test connections', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < 2; i++) {
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true);
WorkflowPage.getters.nodeView().click((i + 1) * 200, (i + 1) * 200, { force: true });
}
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.addNodeToCanvas(MERGE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
// Connect manual to Set1
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('output', MANUAL_TRIGGER_NODE_DISPLAY_NAME),
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
);
// Connect Set1 and Set2 to merge
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('output', EDIT_FIELDS_SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0),
);
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('output', `${EDIT_FIELDS_SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
);
const checkConnections = () => {
WorkflowPage.getters
.getConnectionBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
`${EDIT_FIELDS_SET_NODE_NAME}1`,
)
.should('exist');
WorkflowPage.getters
.getConnectionBetweenNodes(EDIT_FIELDS_SET_NODE_NAME, MERGE_NODE_NAME)
.should('exist');
WorkflowPage.getters
.getConnectionBetweenNodes(`${EDIT_FIELDS_SET_NODE_NAME}1`, MERGE_NODE_NAME)
.should('exist');
};
checkConnections();
// Make sure all connections are there after save & reload
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
cy.waitForLoad();
checkConnections();
// cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
WorkflowPage.actions.executeWorkflow();
WorkflowPage.getters.stopExecutionButton().should('not.exist');
// Make sure all connections are there after save & reload
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
cy.waitForLoad();
checkConnections();
WorkflowPage.actions.executeWorkflow();
WorkflowPage.getters.stopExecutionButton().should('not.exist');
// If the merged set nodes are connected and executed correctly, there should be 2 items in the output of merge node
getConnectionLabelBySourceAndTarget(`${EDIT_FIELDS_SET_NODE_NAME}1`, MERGE_NODE_NAME)
.contains('2 items')
.should('be.visible');
});
it('should add nodes and check execution success', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < 3; i++) {
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME, true);
}
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.executeWorkflow();
cy.get('[data-edge-status=success]').should('have.length', 3);
cy.getByTestId('canvas-node-status-success').should('have.length', 4);
cy.getByTestId('canvas-handle-plus-wrapper').should('have.attr', 'data-plus-type', 'success');
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
cy.getByTestId('canvas-handle-plus').should('not.have.attr', 'data-plus-type', 'success');
cy.get('[data-edge-status=success]').should('have.length', 4);
cy.getByTestId('canvas-node-status-success').should('have.length', 4);
});
it('should delete node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_DISPLAY_NAME, {
method: 'right-click',
anchor: 'topLeft',
});
WorkflowPage.getters.canvasNodes().should('have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should delete node using keyboard shortcut', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(CODE_NODE_DISPLAY_NAME).click();
cy.get('body').type('{backspace}');
WorkflowPage.getters.canvasNodes().should('have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should delete node between two connected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 2);
WorkflowPage.getters.canvasNodeByName(CODE_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.zoomToFit();
cy.get('body').type('{backspace}');
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
});
it('should delete multiple nodes (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
WorkflowPage.actions.hitDeleteAllNodes();
WorkflowPage.getters.canvasNodes().should('have.length', 0);
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
WorkflowPage.actions.selectAllFromContextMenu();
openContextMenu();
clickContextMenuAction('delete');
WorkflowPage.getters.canvasNodes().should('have.length', 0);
});
it('should delete multiple nodes (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
WorkflowPage.actions.hitDeleteAllNodes();
WorkflowPage.getters.canvasNodes().should('have.length', 0);
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
WorkflowPage.actions.selectAllFromContextMenu();
openContextMenu();
clickContextMenuAction('delete');
WorkflowPage.getters.canvasNodes().should('have.length', 0);
});
it('should move node', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
getCanvasNodes()
.last()
.then(($node) => {
const { x: x1, y: y1 } = $node[0].getBoundingClientRect();
cy.drag(getCanvasNodes().last(), [50, 150], {
realMouse: true,
abs: true,
});
getCanvasNodes()
.last()
.then(($node) => {
const { x: x2, y: y2 } = $node[0].getBoundingClientRect();
expect(x2).to.be.greaterThan(x1);
expect(y2).to.be.greaterThan(y1);
});
});
});
describe('Canvas Zoom Functionality', () => {
const getContainer = () => WorkflowPage.getters.canvasViewport();
const checkZoomLevel = (expectedFactor: number) => {
return getContainer().should(($nodeView) => {
const newTransform = $nodeView.css('transform');
const newScale = parseFloat(newTransform.split(',')[0].slice(7));
expect(newScale).to.be.closeTo(expectedFactor, 0.2);
});
};
const zoomAndCheck = (action: 'zoomIn' | 'zoomOut', expectedFactor: number) => {
WorkflowPage.getters[`${action}Button`]().click();
checkZoomLevel(expectedFactor);
};
it('should zoom in', () => {
WorkflowPage.getters.zoomInButton().should('be.visible');
getContainer().then(($nodeView) => {
const initialTransform = $nodeView.css('transform');
const initialScale =
initialTransform === 'none' ? 1 : parseFloat(initialTransform.split(',')[0].slice(7));
zoomAndCheck('zoomIn', initialScale * ZOOM_IN_X1_FACTOR);
zoomAndCheck('zoomIn', initialScale * ZOOM_IN_X2_FACTOR);
});
});
it('should zoom out', () => {
zoomAndCheck('zoomOut', ZOOM_OUT_X1_FACTOR);
zoomAndCheck('zoomOut', ZOOM_OUT_X2_FACTOR);
});
it('should reset zoom', () => {
WorkflowPage.getters.resetZoomButton().should('not.exist');
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters.resetZoomButton().should('be.visible').click();
checkZoomLevel(DEFAULT_ZOOM_FACTOR);
});
it('should zoom to fit', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.zoomOutButton().click();
WorkflowPage.getters.zoomOutButton().click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters.canvasNodes().last().should('not.be.visible');
WorkflowPage.getters.zoomToFitButton().click();
WorkflowPage.getters.canvasNodes().last().should('be.visible');
});
});
it('should disable node (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().last().click();
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 1);
WorkflowPage.actions.disableNode(CODE_NODE_DISPLAY_NAME);
WorkflowPage.getters.disabledNodes().should('have.length', 0);
});
it('should disable multiple nodes (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.get('body').type('{esc}');
cy.get('body').type('{esc}');
// Keyboard shortcut
WorkflowPage.actions.hitSelectAll();
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 2);
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 0);
WorkflowPage.actions.deselectAll();
getCanvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 1);
WorkflowPage.actions.hitSelectAll();
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 2);
// Context menu
WorkflowPage.actions.hitSelectAll();
openContextMenu();
WorkflowPage.actions.contextMenuAction('toggle_activation');
WorkflowPage.getters.disabledNodes().should('have.length', 0);
openContextMenu();
WorkflowPage.actions.contextMenuAction('toggle_activation');
WorkflowPage.getters.disabledNodes().should('have.length', 2);
WorkflowPage.actions.deselectAll();
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
openContextMenu();
WorkflowPage.actions.contextMenuAction('toggle_activation');
WorkflowPage.getters.disabledNodes().should('have.length', 1);
WorkflowPage.actions.hitSelectAll();
openContextMenu();
WorkflowPage.actions.contextMenuAction('toggle_activation');
WorkflowPage.getters.disabledNodes().should('have.length', 2);
});
it('should rename node (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().last().click();
cy.get('body').trigger('keydown', { key: 'F2' });
cy.get('.rename-prompt').should('be.visible');
cy.get('body').type(RENAME_NODE_NAME);
cy.get('body').type('{enter}');
WorkflowPage.getters.canvasNodeByName(RENAME_NODE_NAME).should('exist');
WorkflowPage.actions.renameNode(RENAME_NODE_NAME);
cy.get('.rename-prompt').should('be.visible');
cy.get('body').type(RENAME_NODE_NAME2);
cy.get('body').type('{enter}');
WorkflowPage.getters.canvasNodeByName(RENAME_NODE_NAME2).should('exist');
});
it('should not allow empty strings for node names', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().last().click();
cy.get('body').trigger('keydown', { key: 'F2' });
cy.get('.rename-prompt').should('be.visible');
cy.get('body').type('{backspace}');
cy.get('body').type('{enter}');
cy.get('.rename-prompt').should('contain', 'Invalid Name');
});
it('should duplicate nodes (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.duplicateNode(CODE_NODE_DISPLAY_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.hitSelectAll();
WorkflowPage.actions.hitDuplicateNode();
WorkflowPage.getters.canvasNodes().should('have.length', 5);
});
// ADO-1240: Connections would get deleted after activating and deactivating NodeView
it('should preserve connections after rename & node-view switch', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.executeWorkflow();
successToast().should('contain.text', 'Workflow executed successfully');
clearNotifications();
ExecutionsTab.actions.switchToExecutionsTab();
ExecutionsTab.getters.successfulExecutionListItems().should('have.length', 1);
ExecutionsTab.actions.switchToEditorTab();
ExecutionsTab.actions.switchToExecutionsTab();
ExecutionsTab.getters.successfulExecutionListItems().should('have.length', 1);
ExecutionsTab.actions.switchToEditorTab();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.canvasNodes().last().click();
cy.get('body').trigger('keydown', { key: 'F2' });
cy.get('.rename-prompt').should('be.visible');
cy.get('body').type(RENAME_NODE_NAME);
cy.get('body').type('{enter}');
WorkflowPage.getters.canvasNodeByName(RENAME_NODE_NAME).should('exist');
// Make sure all connections are there after save & reload
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
cy.waitForLoad();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
});
it('should remove unknown credentials on pasting workflow', () => {
cy.fixture('workflow-with-unknown-credentials.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
WorkflowPage.actions.openNodeFromContextMenu('n8n');
cy.get('[class*=hasIssues]').should('have.length', 1);
NDVDialog.actions.close();
});
});
it('should open and close the about modal on keyboard shortcut', () => {
WorkflowPage.actions.hitOpenAbout();
cy.getByTestId('close-about-modal-button').click();
});
});

View File

@ -1,251 +0,0 @@
import { expandSidebar } from '../../composables/sidebar';
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../../constants';
import { MainSidebar, SettingsSidebar, SettingsUsersPage } from '../../pages';
import { errorToast, successToast } from '../../pages/notifications';
import { PersonalSettingsPage } from '../../pages/settings-personal';
import { getVisiblePopper } from '../../utils';
/**
* User A - Instance owner
* User B - User, owns C1, W1, W2
* User C - User, owns C2
*
* W1 - Workflow owned by User B, shared with User C
* W2 - Workflow owned by User B
*
* C1 - Credential owned by User B
* C2 - Credential owned by User C, shared with User A and User B
*/
const updatedPersonalData = {
newFirstName: 'Something',
newLastName: 'Else',
newEmail: 'something_else@acme.corp',
newPassword: 'Keybo4rd',
invalidPasswords: ['abc', 'longEnough', 'longenough123'],
};
const usersSettingsPage = new SettingsUsersPage();
const personalSettingsPage = new PersonalSettingsPage();
const settingsSidebar = new SettingsSidebar();
const mainSidebar = new MainSidebar();
describe('User Management', { disableAutoLogin: true }, () => {
before(() => {
cy.enableFeature('sharing');
});
it('should login and logout', () => {
cy.visit('/');
cy.get('input[name="emailOrLdapLoginId"]').type(INSTANCE_OWNER.email);
cy.get('input[name="password"]').type(INSTANCE_OWNER.password);
cy.getByTestId('form-submit-button').click();
mainSidebar.getters.logo().should('be.visible');
expandSidebar();
mainSidebar.actions.goToSettings();
settingsSidebar.getters.users().should('be.visible');
mainSidebar.actions.closeSettings();
mainSidebar.actions.openUserMenu();
cy.getByTestId('user-menu-item-logout').click();
cy.get('input[name="emailOrLdapLoginId"]').type(INSTANCE_MEMBERS[0].email);
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
cy.getByTestId('form-submit-button').click();
mainSidebar.getters.logo().should('be.visible');
mainSidebar.actions.goToSettings();
cy.getByTestId('menu-item').filter('#settings-users').should('not.exist');
});
it('should prevent non-owners to access UM settings', () => {
usersSettingsPage.actions.loginAndVisit(
INSTANCE_MEMBERS[0].email,
INSTANCE_MEMBERS[0].password,
false,
);
});
it('should allow instance owner to access UM settings', () => {
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
});
it('should properly render UM settings page for instance owners', () => {
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
// All items in user list should be there
usersSettingsPage.getters.userListItems().should('have.length', 4);
// List item for current user should have the `Owner` badge
usersSettingsPage.getters
.userItem(INSTANCE_OWNER.email)
.find('td:contains("Owner")')
.should('be.visible');
// Other users list items should contain action pop-up list
usersSettingsPage.getters.userActionsToggle(INSTANCE_MEMBERS[0].email).should('exist');
usersSettingsPage.getters.userActionsToggle(INSTANCE_MEMBERS[1].email).should('exist');
usersSettingsPage.getters.userActionsToggle(INSTANCE_ADMIN.email).should('exist');
});
it('should be able to change user role to Admin and back', () => {
cy.enableFeature('advancedPermissions');
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
// Change role from Member to Admin
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('button:contains("Member")')
.should('be.visible')
.click();
getVisiblePopper().find('label').contains('Admin').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('button:contains("Admin")')
.should('be.visible');
usersSettingsPage.actions.loginAndVisit(
INSTANCE_MEMBERS[0].email,
INSTANCE_MEMBERS[0].password,
true,
);
// Change role from Admin to Member, then back to Admin
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('button:contains("Admin")')
.should('be.visible')
.click();
getVisiblePopper().find('label').contains('Member').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('button:contains("Member")')
.should('be.visible');
usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, false);
usersSettingsPage.actions.loginAndVisit(
INSTANCE_MEMBERS[0].email,
INSTANCE_MEMBERS[0].password,
true,
);
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('button:contains("Member")')
.should('be.visible')
.click();
getVisiblePopper().find('label').contains('Admin').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('button:contains("Admin")')
.should('be.visible');
usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, true);
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('button:contains("Admin")')
.should('be.visible')
.click();
getVisiblePopper().find('label').contains('Member').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('button:contains("Member")')
.should('be.visible');
cy.disableFeature('advancedPermissions');
});
it('should be able to change theme', () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.actions.changeTheme('Dark');
cy.get('body').should('have.attr', 'data-theme', 'dark');
personalSettingsPage.actions.changeTheme('Light');
cy.get('body').should('have.attr', 'data-theme', 'light');
});
it('should delete user and their data', () => {
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
usersSettingsPage.actions.opedDeleteDialog(INSTANCE_MEMBERS[0].email);
usersSettingsPage.getters.deleteDataRadioButton().click();
usersSettingsPage.getters.deleteDataInput().type('delete all data');
usersSettingsPage.getters.deleteUserButton().click();
successToast().should('contain', 'User deleted');
});
it('should delete user and transfer their data', () => {
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
usersSettingsPage.actions.opedDeleteDialog(INSTANCE_MEMBERS[1].email);
usersSettingsPage.getters.transferDataRadioButton().click();
usersSettingsPage.getters.userSelectDropDown().click();
usersSettingsPage.getters.userSelectOptions().first().click();
usersSettingsPage.getters.deleteUserButton().click();
successToast().should('contain', 'User deleted');
});
it('should allow user to change their personal data', () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.actions.updateFirstAndLastName(
updatedPersonalData.newFirstName,
updatedPersonalData.newLastName,
);
personalSettingsPage.getters
.currentUserName()
.should('contain', `${updatedPersonalData.newFirstName} ${updatedPersonalData.newLastName}`);
successToast().should('contain', 'Personal details updated');
});
it("shouldn't allow user to set weak password", () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.getters.changePasswordLink().click();
for (const weakPass of updatedPersonalData.invalidPasswords) {
personalSettingsPage.actions.tryToSetWeakPassword(INSTANCE_OWNER.password, weakPass);
}
});
it("shouldn't allow user to change password if old password is wrong", () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.getters.changePasswordLink().click();
personalSettingsPage.actions.updatePassword('iCannotRemember', updatedPersonalData.newPassword);
errorToast().closest('div').should('contain', 'Provided current password is incorrect.');
});
it('should change current user password', () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.getters.changePasswordLink().click();
personalSettingsPage.actions.updatePassword(
INSTANCE_OWNER.password,
updatedPersonalData.newPassword,
);
successToast().should('contain', 'Password updated');
personalSettingsPage.actions.loginWithNewData(
INSTANCE_OWNER.email,
updatedPersonalData.newPassword,
);
});
it("shouldn't allow users to set invalid email", () => {
personalSettingsPage.actions.loginAndVisit(
INSTANCE_OWNER.email,
updatedPersonalData.newPassword,
);
// try without @ part
personalSettingsPage.actions.tryToSetInvalidEmail(updatedPersonalData.newEmail.split('@')[0]);
// try without domain
personalSettingsPage.actions.tryToSetInvalidEmail(updatedPersonalData.newEmail.split('.')[0]);
});
it('should change user email', () => {
personalSettingsPage.actions.loginAndVisit(
INSTANCE_OWNER.email,
updatedPersonalData.newPassword,
);
personalSettingsPage.actions.updateEmail(
updatedPersonalData.newEmail,
updatedPersonalData.newPassword,
);
successToast().should('contain', 'Personal details updated');
personalSettingsPage.actions.loginWithNewData(
updatedPersonalData.newEmail,
updatedPersonalData.newPassword,
);
});
});

View File

@ -1,107 +0,0 @@
import { WorkflowPage, NDV, CredentialsModal } from '../../pages';
import { getVisiblePopper } from '../../utils';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
const credentialsModal = new CredentialsModal();
const NO_CREDENTIALS_MESSAGE = 'Add your credential';
const INVALID_CREDENTIALS_MESSAGE = 'Check your credential';
const MODE_SELECTOR_LIST = 'From list';
describe('Resource Locator', () => {
beforeEach(() => {
workflowPage.actions.visit();
});
it('should render both RLC components in google sheets', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true, 'Update row in sheet');
ndv.getters.resourceLocator('documentId').should('be.visible');
ndv.getters.resourceLocator('sheetName').should('be.visible');
ndv.getters
.resourceLocatorModeSelector('documentId')
.find('input')
.should('have.value', MODE_SELECTOR_LIST);
ndv.getters
.resourceLocatorModeSelector('sheetName')
.find('input')
.should('have.value', MODE_SELECTOR_LIST);
});
it('should show appropriate error when credentials are not set', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true, 'Update row in sheet');
ndv.getters.resourceLocator('documentId').should('be.visible');
ndv.getters.resourceLocatorInput('documentId').click();
ndv.getters.resourceLocatorErrorMessage().should('contain', NO_CREDENTIALS_MESSAGE);
});
it('should show create credentials modal when clicking "add your credential"', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true, 'Update row in sheet');
ndv.getters.resourceLocator('documentId').should('be.visible');
ndv.getters.resourceLocatorInput('documentId').click();
ndv.getters.resourceLocatorErrorMessage().should('contain', NO_CREDENTIALS_MESSAGE);
ndv.getters.resourceLocatorAddCredentials().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
});
it('should show appropriate error when credentials are not valid', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true, 'Update row in sheet');
// Add oAuth credentials
workflowPage.getters.nodeCredentialsSelect().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
credentialsModal.actions.fillCredentialsForm();
cy.get('.el-message-box').find('button').contains('Close').click();
ndv.getters.resourceLocatorInput('documentId').click();
ndv.getters.resourceLocatorErrorMessage().should('contain', INVALID_CREDENTIALS_MESSAGE);
});
it('should show appropriate errors when search filter is required', () => {
workflowPage.actions.addNodeToCanvas('Github', true, true, 'On pull request');
ndv.getters.resourceLocator('owner').should('be.visible');
ndv.getters.resourceLocatorInput('owner').click();
ndv.getters.resourceLocatorErrorMessage().should('contain', NO_CREDENTIALS_MESSAGE);
});
it('should reset resource locator when dependent field is changed', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true, 'Update row in sheet');
ndv.actions.setRLCValue('documentId', '123');
ndv.actions.setRLCValue('sheetName', '123');
ndv.actions.setRLCValue('documentId', '321');
ndv.getters.resourceLocatorInput('sheetName').should('have.value', '');
});
// unlike RMC and remote options, RLC does not support loadOptionDependsOn
it('should retrieve list options when other params throw errors', () => {
workflowPage.actions.addInitialNodeToCanvas('E2e Test', { action: 'Resource Locator' });
ndv.getters.resourceLocatorInput('rlc').click();
cy.getByTestId('rlc-item').should('exist');
getVisiblePopper()
.should('have.length', 1)
.findChildByTestId('rlc-item')
.should('have.length', 5);
ndv.actions.setInvalidExpression({ fieldName: 'fieldId' });
ndv.getters.inputPanel().click(); // remove focus from input, hide expression preview
ndv.getters.resourceLocatorInput('rlc').click();
cy.getByTestId('rlc-item').should('exist');
getVisiblePopper()
.should('have.length', 1)
.findChildByTestId('rlc-item')
.should('have.length', 5);
});
});

View File

@ -1,124 +0,0 @@
import { overrideFeatureFlag } from '../../composables/featureFlags';
import { expandSidebar } from '../../composables/sidebar';
import planData from '../../fixtures/Plan_data_opt_in_trial.json';
import {
MainSidebar,
WorkflowPage,
visitPublicApiPage,
getPublicApiUpgradeCTA,
WorkflowsPage,
} from '../../pages';
const NUMBER_OF_AI_CREDITS = 100;
const mainSidebar = new MainSidebar();
const workflowPage = new WorkflowPage();
const workflowsPage = new WorkflowsPage();
describe('Cloud', () => {
before(() => {
const now = new Date();
const fiveDaysFromNow = new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000);
planData.expirationDate = fiveDaysFromNow.toJSON();
});
beforeEach(() => {
cy.overrideSettings({
deployment: { type: 'cloud' },
n8nMetadata: { userId: '1' },
aiCredits: {
enabled: true,
credits: NUMBER_OF_AI_CREDITS,
},
});
cy.intercept('GET', '/rest/admin/cloud-plan', planData).as('getPlanData');
cy.intercept('GET', '/rest/cloud/proxy/user/me', {}).as('getCloudUserInfo');
cy.intercept('GET', new RegExp('/rest/projects*')).as('projects');
cy.intercept('GET', new RegExp('/rest/roles')).as('roles');
});
function visitWorkflowPage() {
cy.visit(workflowPage.url);
cy.wait('@getPlanData');
cy.wait('@projects');
cy.wait('@roles');
}
describe('BannerStack', () => {
it('should not render trial banner on first visit', () => {
// Clear localStorage to simulate first visit
cy.clearLocalStorage();
visitWorkflowPage();
cy.getByTestId('banner-stack').should('not.be.visible');
});
it('should render trial banner on subsequent visits', () => {
// Set visit count to simulate returning user
cy.window().then((win) => {
win.localStorage.setItem('n8n-trial-visit-count', '1');
});
visitWorkflowPage();
expandSidebar();
cy.getByTestId('banner-stack').should('be.visible');
mainSidebar.actions.signout();
cy.getByTestId('banner-stack').should('not.be.visible');
});
});
describe('Admin Home', () => {
it('Should show admin button', () => {
visitWorkflowPage();
expandSidebar();
mainSidebar.getters.adminPanel().should('be.visible');
});
});
describe('Public API', () => {
it('Should show upgrade CTA for Public API if user is trialing', () => {
visitPublicApiPage();
cy.wait(['@loadSettings', '@projects', '@roles', '@getPlanData']);
getPublicApiUpgradeCTA().should('be.visible');
});
});
describe('Easy AI workflow experiment', () => {
it('should not show option to take you to the easy AI workflow if experiment is control', () => {
overrideFeatureFlag('026_easy_ai_workflow', 'control');
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').should('not.exist');
});
it('should show option to take you to the easy AI workflow if experiment is variant', () => {
overrideFeatureFlag('026_easy_ai_workflow', 'variant');
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').should('to.exist');
});
it('should show default instructions if free AI credits experiment is control', () => {
overrideFeatureFlag('026_easy_ai_workflow', 'variant');
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').click();
workflowPage.getters
.stickies()
.eq(0)
.should(($el) => {
expect($el).contains.text('Start by saying');
});
});
});
});

View File

@ -1,608 +0,0 @@
import { clickGetBackToCanvas, getNdvContainer, getOutputTableRow } from '../../composables/ndv';
import {
clickExecuteWorkflowButton,
getExecuteWorkflowButton,
getNodeByName,
getZoomToFitButton,
openNode,
} from '../../composables/workflow';
import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../../constants';
import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../../pages';
import { clearNotifications, errorToast, successToast } from '../../pages/notifications';
const workflowPage = new WorkflowPageClass();
const executionsTab = new WorkflowExecutionsTab();
const ndv = new NDV();
describe('Execution', () => {
beforeEach(() => {
workflowPage.actions.visit();
});
it('should test manual workflow', () => {
cy.createFixtureWorkflow('Manual_wait_set.json');
// Check workflow buttons
workflowPage.getters.executeWorkflowButton().should('be.visible');
workflowPage.getters.clearExecutionDataButton().should('not.exist');
workflowPage.getters.stopExecutionButton().should('not.exist');
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist');
// Execute the workflow
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
// Check workflow buttons
workflowPage.getters.executeWorkflowButton().get('.n8n-spinner').should('be.visible');
workflowPage.getters.clearExecutionDataButton().should('not.exist');
workflowPage.getters.stopExecutionButton().should('be.visible');
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist');
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=refresh-cw]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
cy.wait(2000);
// Check canvas nodes after 2nd step (waiting node finished its execution and the http request node is about to start)
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
successToast().should('be.visible');
clearNotifications();
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
workflowPage.getters.clearExecutionDataButton().click();
workflowPage.getters.clearExecutionDataButton().should('not.exist');
});
it('should test manual workflow stop', () => {
cy.createFixtureWorkflow('Manual_wait_set.json');
// Check workflow buttons
workflowPage.getters.executeWorkflowButton().should('be.visible');
workflowPage.getters.clearExecutionDataButton().should('not.exist');
workflowPage.getters.stopExecutionButton().should('not.exist');
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist');
// Execute the workflow
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
// Check workflow buttons
workflowPage.getters.executeWorkflowButton().get('.n8n-spinner').should('be.visible');
workflowPage.getters.clearExecutionDataButton().should('not.exist');
workflowPage.getters.stopExecutionButton().should('be.visible');
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist');
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=refresh-cw]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
successToast().should('be.visible');
clearNotifications();
workflowPage.getters.stopExecutionButton().should('exist');
workflowPage.getters.stopExecutionButton().click();
// Check canvas nodes after workflow stopped
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=refresh-cw]').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
successToast().should('be.visible');
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
workflowPage.getters.clearExecutionDataButton().click();
workflowPage.getters.clearExecutionDataButton().should('not.exist');
});
it('should test webhook workflow', () => {
cy.createFixtureWorkflow('Webhook_wait_set.json');
// Check workflow buttons
workflowPage.getters.executeWorkflowButton().should('be.visible');
workflowPage.getters.clearExecutionDataButton().should('not.exist');
workflowPage.getters.stopExecutionButton().should('not.exist');
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist');
// Execute the workflow
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
// Check workflow buttons
workflowPage.getters.executeWorkflowButton().get('.n8n-spinner').should('be.visible');
workflowPage.getters.clearExecutionDataButton().should('not.exist');
workflowPage.getters.stopExecutionButton().should('not.exist');
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('be.visible');
workflowPage.getters.canvasNodes().first().dblclick();
ndv.getters.copyInput().click();
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
ndv.getters.backToCanvas().click();
cy.readClipboard().then((url) => {
cy.request({
method: 'GET',
url,
}).then((resp) => {
expect(resp.status).to.eq(200);
});
});
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters
.canvasNodeByName('Webhook')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=refresh-cw]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
cy.wait(2000);
// Check canvas nodes after 2nd step (waiting node finished its execution and the http request node is about to start)
workflowPage.getters
.canvasNodeByName('Webhook')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
successToast().should('be.visible');
clearNotifications();
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
workflowPage.getters.clearExecutionDataButton().click();
workflowPage.getters.clearExecutionDataButton().should('not.exist');
});
it('should test workflow with specific trigger node', () => {
cy.createFixtureWorkflow('Two_schedule_triggers.json');
getZoomToFitButton().click();
getExecuteWorkflowButton('Trigger A').should('not.be.visible');
getExecuteWorkflowButton('Trigger B').should('not.be.visible');
// Execute the workflow from trigger A
getNodeByName('Trigger A').realHover();
getExecuteWorkflowButton('Trigger A').should('be.visible');
getExecuteWorkflowButton('Trigger B').should('not.be.visible');
clickExecuteWorkflowButton('Trigger A');
// Check the output
successToast().contains('Workflow executed successfully');
openNode('Edit Fields');
getOutputTableRow(1).should('include.text', 'Trigger A');
clickGetBackToCanvas();
getNdvContainer().should('not.be.visible');
// Execute the workflow from trigger B
getNodeByName('Trigger B').realHover();
getExecuteWorkflowButton('Trigger A').should('not.be.visible');
getExecuteWorkflowButton('Trigger B').should('be.visible');
clickExecuteWorkflowButton('Trigger B');
// Check the output
successToast().contains('Workflow executed successfully');
openNode('Edit Fields');
getOutputTableRow(1).should('include.text', 'Trigger B');
});
describe('execution preview', () => {
it('when deleting the last execution, it should show empty state', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
workflowPage.actions.executeWorkflow();
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.deleteExecutionInPreview();
executionsTab.getters.successfulExecutionListItems().should('have.length', 0);
successToast().contains('Execution deleted');
});
});
/**
* @TODO New Canvas: Different classes for pinned states on edges and nodes
*/
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('connections should be colored differently for pinned data', () => {
beforeEach(() => {
cy.createFixtureWorkflow('Schedule_pinned.json');
workflowPage.actions.deselectAll();
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters
.getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('not.have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields1')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('not.have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields5', 'Edit Fields6')
.should('not.have.class', 'success')
.should('not.have.class', 'pinned');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields7', 'Edit Fields9')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('not.have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields1', 'Edit Fields2')
.should('not.have.class', 'success')
.should('not.have.class', 'pinned');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields2', 'Edit Fields3')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('not.have.class', 'has-run');
});
it('when executing the workflow', () => {
workflowPage.actions.executeWorkflow();
workflowPage.getters
.getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields1')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields5', 'Edit Fields6')
.should('have.class', 'success')
.should('not.have.class', 'pinned')
.should('not.have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields7', 'Edit Fields9')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields1', 'Edit Fields2')
.should('have.class', 'success')
.should('not.have.class', 'pinned')
.should('not.have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields2', 'Edit Fields3')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
});
it('when executing a node', () => {
workflowPage.actions.executeNode('Edit Fields3');
workflowPage.getters
.getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields1')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields5', 'Edit Fields6')
.should('not.have.class', 'success')
.should('not.have.class', 'pinned')
.should('not.have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields7', 'Edit Fields9')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('not.have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields1', 'Edit Fields2')
.should('have.class', 'success')
.should('not.have.class', 'pinned')
.should('not.have.class', 'has-run');
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields2', 'Edit Fields3')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
});
it('when connecting pinned node by output drag and drop', () => {
cy.drag(
workflowPage.getters.getEndpointSelector('output', SCHEDULE_TRIGGER_NODE_NAME),
[-200, -300],
);
workflowPage.getters.nodeCreatorSearchBar().should('be.visible');
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false);
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [150, 200], {
clickToFinish: true,
});
workflowPage.getters
.getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('not.have.class', 'has-run');
workflowPage.actions.executeWorkflow();
workflowPage.getters
.getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
cy.drag(workflowPage.getters.getEndpointSelector('output', 'Edit Fields2'), [-200, -300]);
workflowPage.getters.nodeCreatorSearchBar().should('be.visible');
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false);
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [150, 200], {
clickToFinish: true,
});
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields2', 'Edit Fields11')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
});
it('when connecting pinned node after adding an unconnected node', () => {
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
cy.draganddrop(
workflowPage.getters.getEndpointSelector('output', SCHEDULE_TRIGGER_NODE_NAME),
workflowPage.getters.getEndpointSelector('input', 'Edit Fields8'),
);
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters
.getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('not.have.class', 'has-run');
workflowPage.actions.executeWorkflow();
workflowPage.getters
.getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
workflowPage.actions.deselectAll();
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.getters.zoomToFitButton().click();
cy.draganddrop(
workflowPage.getters.getEndpointSelector('output', 'Edit Fields7'),
workflowPage.getters.getEndpointSelector('input', 'Edit Fields11'),
);
workflowPage.getters
.getConnectionBetweenNodes('Edit Fields7', 'Edit Fields11')
.should('have.class', 'success')
.should('have.class', 'pinned')
.should('have.class', 'has-run');
});
});
it('should send proper payload for node rerun', () => {
cy.createFixtureWorkflow('Multiple_trigger_node_rerun.json');
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
workflowPage.getters.clearExecutionDataButton().should('be.visible');
cy.intercept('POST', '/rest/workflows/**/run?**').as('workflowRun');
workflowPage.getters
.canvasNodeByName('Process The Data')
.findChildByTestId('execute-node-button')
.click({ force: true });
cy.wait('@workflowRun').then((interception) => {
expect(interception.request.body).to.have.property('runData').that.is.an('object');
const expectedKeys = ['Start on Schedule', 'Edit Fields', 'Process The Data'];
const { runData } = interception.request.body;
expect(Object.keys(runData)).to.have.lengthOf(expectedKeys.length);
expect(runData).to.include.all.keys(expectedKeys);
});
});
it('should send proper payload for manual node run', () => {
cy.createFixtureWorkflow('Check_manual_node_run_for_pinned_and_rundata.json');
workflowPage.getters.zoomToFitButton().click();
cy.intercept('POST', '/rest/workflows/**/run?**').as('workflowRun');
workflowPage.getters
.canvasNodeByName('If')
.findChildByTestId('execute-node-button')
.click({ force: true });
cy.wait('@workflowRun').then((interception) => {
expect(interception.request.body).not.to.have.property('runData').that.is.an('object');
expect(interception.request.body).to.have.property('workflowData').that.is.an('object');
expect(interception.request.body.workflowData)
.to.have.property('pinData')
.that.is.an('object');
const expectedPinnedDataKeys = ['Webhook'];
const { pinData } = interception.request.body.workflowData as Record<string, object>;
expect(Object.keys(pinData)).to.have.lengthOf(expectedPinnedDataKeys.length);
expect(pinData).to.include.all.keys(expectedPinnedDataKeys);
});
workflowPage.getters.clearExecutionDataButton().should('be.visible');
cy.intercept('POST', '/rest/workflows/**/run?**').as('workflowRun');
workflowPage.getters
.canvasNodeByName('NoOp2')
.findChildByTestId('execute-node-button')
.click({ force: true });
cy.wait('@workflowRun').then((interception) => {
expect(interception.request.body).to.have.property('runData').that.is.an('object');
expect(interception.request.body).to.have.property('workflowData').that.is.an('object');
expect(interception.request.body.workflowData)
.to.have.property('pinData')
.that.is.an('object');
const expectedPinnedDataKeys = ['Webhook'];
const expectedRunDataKeys = ['If', 'Webhook'];
const { pinData } = interception.request.body.workflowData as Record<string, object>;
expect(Object.keys(pinData)).to.have.lengthOf(expectedPinnedDataKeys.length);
expect(pinData).to.include.all.keys(expectedPinnedDataKeys);
const { runData } = interception.request.body as Record<string, object>;
expect(Object.keys(runData)).to.have.lengthOf(expectedRunDataKeys.length);
expect(runData).to.include.all.keys(expectedRunDataKeys);
});
});
it('should successfully execute partial executions with nodes attached to the second output', () => {
cy.createFixtureWorkflow('Test_Workflow_pairedItem_incomplete_manual_bug.json');
cy.intercept('POST', '/rest/workflows/**/run?**').as('workflowRun');
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
workflowPage.getters
.canvasNodeByName('Test Expression')
.findChildByTestId('execute-node-button')
.click({ force: true });
// Check toast (works because Cypress waits enough for the element to show after the http request node has finished)
// Wait for the execution to return.
cy.wait('@workflowRun');
// Wait again for the websocket message to arrive and the UI to update.
cy.wait(100);
errorToast({ timeout: 1 }).should('not.exist');
});
it('should execute workflow partially up to the node that has issues', () => {
cy.createFixtureWorkflow('Test_workflow_partial_execution_with_missing_credentials.json');
cy.intercept('POST', '/rest/workflows/**/run?**').as('workflowRun');
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
// Wait for the execution to return.
cy.wait('@workflowRun');
// Check that the previous nodes executed successfully
workflowPage.getters
.canvasNodeByName('DebugHelper')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Filter')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
errorToast().should('contain', 'Problem in node ‘Telegram‘');
});
it('Paired items should be correctly mapped after passed through the merge node with more than two inputs', () => {
cy.createFixtureWorkflow('merge_node_inputs_paired_items.json');
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
workflowPage.getters
.canvasNodeByName('Edit Fields')
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters.canvasNodeByName('Edit Fields').dblclick();
ndv.actions.switchOutputMode('JSON');
ndv.getters.outputPanel().contains('Branch 1 Value').should('be.visible');
ndv.getters.outputPanel().contains('Branch 2 Value').should('be.visible');
ndv.getters.outputPanel().contains('Branch 3 Value').should('be.visible');
});
});

View File

@ -1,581 +0,0 @@
import { createResource } from '../../composables/create';
import { transferWorkflow } from '../../composables/folders';
import { setCredentialValues } from '../../composables/modals/credential-modal';
import {
clickCreateNewCredential,
getNdvContainer,
selectResourceLocatorAddResourceItem,
clickGetBackToCanvas,
} from '../../composables/ndv';
import * as projects from '../../composables/projects';
import {
EDIT_FIELDS_SET_NODE_NAME,
INSTANCE_ADMIN,
INSTANCE_MEMBERS,
INSTANCE_OWNER,
MANUAL_TRIGGER_NODE_NAME,
NOTION_NODE_NAME,
} from '../../constants';
import {
WorkflowsPage,
WorkflowPage,
CredentialsModal,
CredentialsPage,
NDV,
MainSidebar,
} from '../../pages';
import { clearNotifications, successToast } from '../../pages/notifications';
import { getVisibleSelect } from '../../utils';
const workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPage();
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
const ndv = new NDV();
const mainSidebar = new MainSidebar();
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Projects', { disableAutoLogin: true }, () => {
describe('when starting from scratch', () => {
beforeEach(() => {
cy.resetDatabase();
cy.enableFeature('sharing');
cy.enableFeature('folders');
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
cy.changeQuota('maxTeamProjects', -1);
});
it('should not show project add button and projects to a member if not invited to any project', () => {
cy.signinAsMember(1);
cy.visit(workflowsPage.url);
cy.getByTestId('add-project-menu-item').should('not.exist');
projects.getMenuItems().should('not.exist');
});
it('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
// Create a project and add a credential to it
cy.intercept('POST', '/rest/projects').as('projectCreate');
projects.getAddProjectButton().click();
cy.wait('@projectCreate');
projects.getMenuItems().should('have.length', 1);
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('not.have.length');
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters
.connectionParameter('Internal Integration Secret')
.type('1234567890');
credentialsModal.actions.setName('Notion account project 1');
cy.intercept('POST', '/rest/credentials').as('credentialSave');
credentialsModal.actions.save();
cy.wait('@credentialSave').then((interception) => {
expect(interception.request.body).to.have.property('projectId');
});
credentialsModal.actions.close();
// Create another project and add a credential to it
projects.getAddProjectButton().click();
cy.wait('@projectCreate');
projects.getMenuItems().should('have.length', 2);
projects.getMenuItems().last().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('not.have.length');
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters
.connectionParameter('Internal Integration Secret')
.type('1234567890');
credentialsModal.actions.setName('Notion account project 2');
credentialsModal.actions.save();
cy.wait('@credentialSave').then((interception) => {
expect(interception.request.body).to.have.property('projectId');
});
credentialsModal.actions.close();
// Create a credential in Home project
projects.getHomeButton().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 2);
credentialsPage.getters.createCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters
.connectionParameter('Internal Integration Secret')
.type('1234567890');
credentialsModal.actions.setName('Notion account personal project');
cy.intercept('POST', '/rest/credentials').as('credentialSave');
credentialsModal.actions.save();
cy.wait('@credentialSave');
credentialsModal.actions.close();
// Go to the first project and create a workflow
projects.getMenuItems().first().click();
workflowsPage.getters.workflowCards().should('not.have.length');
cy.intercept('GET', '/rest/credentials/for-workflow*').as('getCredentialsForWorkflow');
workflowsPage.getters.newWorkflowButtonCard().click();
cy.wait('@getCredentialsForWorkflow').then((interception) => {
expect(interception.request.query).to.have.property('projectId');
expect(interception.request.query).not.to.have.property('workflowId');
});
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 1');
ndv.getters.backToCanvas().click();
workflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
cy.wait('@getCredentialsForWorkflow').then((interception) => {
expect(interception.request.query).not.to.have.property('projectId');
expect(interception.request.query).to.have.property('workflowId');
});
workflowPage.getters.canvasNodeByName('Append a block').should('be.visible').dblclick();
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 1');
ndv.getters.backToCanvas().click();
// Go to the second project and create a workflow
projects.getMenuItems().last().click();
workflowsPage.getters.workflowCards().should('not.have.length');
workflowsPage.getters.newWorkflowButtonCard().click();
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 2');
ndv.getters.backToCanvas().click();
workflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
workflowPage.getters.canvasNodeByName('Append a block').should('be.visible').dblclick();
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 2');
ndv.getters.backToCanvas().click();
// Go to the Home project and create a workflow
projects.getHomeButton().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 3);
projects.getProjectTabWorkflows().click();
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.createWorkflowButton().click();
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account personal project');
ndv.getters.backToCanvas().click();
workflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
workflowPage.getters.canvasNodeByName('Append a block').should('be.visible').dblclick();
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account personal project');
});
it('should allow changing an inaccessible credential when the workflow was moved to a team project', () => {
cy.intercept('GET', /\/rest\/(workflows|credentials).*/).as('getResources');
cy.signinAsOwner();
cy.visit(workflowsPage.url);
// Create a credential in the Home project
projects.getProjectTabCredentials().should('be.visible').click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Home project');
// Create a workflow in the Home project
projects.getHomeButton().click();
workflowsPage.getters.workflowCards().should('not.have.length');
workflowsPage.getters.newWorkflowButtonCard().click();
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
ndv.getters.backToCanvas().click();
workflowPage.actions.saveWorkflowOnButtonClick();
// Create a project and add a user to it
projects.createProject('Project 1');
projects.addProjectMember(INSTANCE_MEMBERS[0].email);
clearNotifications();
projects.getProjectSettingsSaveButton().click();
// Move the workflow from Home to Project 1
projects.getPersonalProjectsButton().click();
workflowsPage.getters.workflowCards().should('have.length', 1);
workflowsPage.getters.workflowCardActions('My workflow').click();
workflowsPage.getters.workflowMoveButton().click();
transferWorkflow('My workflow', 'Project 1', 'No folder (project root)');
projects.getMenuItems().filter(':contains("Project 1")').click();
clearNotifications();
cy.wait('@getResources');
workflowsPage.getters
.workflowCards()
.should('have.length', 1)
.filter(':contains("Personal")')
.should('not.exist');
//Log out with instance owner and log in with the member user
mainSidebar.actions.openUserMenu();
cy.getByTestId('user-menu-item-logout').click();
cy.get('input[name="emailOrLdapLoginId"]').type(INSTANCE_MEMBERS[0].email);
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
cy.getByTestId('form-submit-button').click();
// Open the moved workflow
workflowsPage.getters.workflowCards().should('have.length', 1);
workflowsPage.getters.workflowCards().first().findChildByTestId('card-content').click();
// Check if the credential can be changed
workflowPage.getters.canvasNodeByName('Append a block').should('be.visible').dblclick();
ndv.getters.credentialInput().find('input').should('be.enabled');
});
it('should create sub-workflow and credential in the sub-workflow in the same project', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
projects.createProject('Dev');
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.addNodeToCanvas('Execute Workflow', true, true);
cy.interceptNewTab();
selectResourceLocatorAddResourceItem('workflowId', 'Create a');
cy.visitInterceptedTab();
getNdvContainer().should('be.visible');
clickGetBackToCanvas();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
clickCreateNewCredential();
setCredentialValues({
apiKey: 'abc123',
});
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
projects.getMenuItems().last().click();
workflowsPage.getters.workflowCards().should('have.length', 2);
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 1);
});
it('should create credential from workflow in the correct project after editor page refresh', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
projects.createProject('Dev');
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
clickCreateNewCredential();
setCredentialValues({
apiKey: 'abc123',
});
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
projects.getMenuItems().last().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 1);
});
it('should set and update project icon', () => {
const DEFAULT_ICON = 'layers';
const NEW_PROJECT_NAME = 'Test Project';
cy.signinAsAdmin();
cy.visit(workflowsPage.url);
projects.createProject(NEW_PROJECT_NAME);
// New project should have default icon
projects.getIconPickerButton().find('svg').should('have.attr', 'data-icon', DEFAULT_ICON);
// Choose another icon
projects.getIconPickerButton().click();
projects.getIconPickerTab('Emojis').click();
projects.getIconPickerEmojis().first().click();
// Project should be updated with new icon
successToast().contains('Project icon updated successfully');
projects.getIconPickerButton().should('contain', '😀');
projects.getMenuItems().contains(NEW_PROJECT_NAME).should('contain', '😀');
});
it('should be able to create a workflow when in the workflow editor', () => {
cy.signinAsOwner();
workflowPage.actions.visit();
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.actions.saveWorkflowOnButtonClick();
cy.url().then((url) => {
createResource('workflow', 'Personal');
cy.get('body').click();
workflowPage.getters.canvasNodes().should('not.have.length');
cy.go('back');
cy.url().should('eq', url);
workflowPage.getters.canvasNodes().should('have.length', 2);
createResource('workflow', 'Personal');
cy.url().then((url) => {
const urlObj = new URL(url);
expect(urlObj.pathname).to.include('/workflow/new');
workflowPage.getters.canvasNodes().should('not.have.length');
});
});
});
});
describe('when moving resources between projects', () => {
before(() => {
cy.resetDatabase();
cy.enableFeature('sharing');
cy.enableFeature('folders');
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
cy.changeQuota('maxTeamProjects', -1);
});
it('should create a workflow and a credential in the Home project', () => {
cy.intercept('GET', /\/rest\/(workflows|credentials).*/).as('getResources');
cy.signinAsOwner();
cy.visit(workflowsPage.url);
cy.log('Create a workflow and a credential in the Home project');
workflowsPage.getters.workflowCards().should('not.have.length');
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Home project');
clearNotifications();
projects.getHomeButton().click();
projects.getProjectTabCredentials().should('be.visible').click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Home project');
clearNotifications();
cy.log('Create a project and add a credential and a workflow to it');
projects.createProject('Project 1');
clearNotifications();
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Project 1');
clearNotifications();
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 1');
clearNotifications();
cy.log('Create another project and add a credential and a workflow to it');
projects.createProject('Project 2');
clearNotifications();
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Project 2');
clearNotifications();
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 2');
clearNotifications();
});
it('should move the workflow to expected projects', () => {
cy.intercept('GET', /\/rest\/(workflows|credentials).*/).as('getResources');
cy.signinAsOwner();
cy.visit(workflowsPage.url);
projects.getPersonalProjectsButton().click();
workflowsPage.getters.workflowCards().should('have.length', 1);
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
transferWorkflow('Workflow in Home project', 'Project 2', 'No folder (project root)');
clearNotifications();
workflowsPage.getters.workflowCards().should('have.length', 0);
cy.log('Move the workflow from Project 1 to Project 2');
projects.getMenuItems().first().click();
workflowsPage.getters.workflowCards().should('have.length', 1);
workflowsPage.getters.workflowCardActions('Workflow in Project 1').click();
workflowsPage.getters.workflowMoveButton().click();
transferWorkflow('Workflow in Project 1', 'Project 2', 'No folder (project root)');
clearNotifications();
cy.log('Move the workflow from Project 2 to a member user');
projects.getMenuItems().last().click();
workflowsPage.getters.workflowCards().should('have.length', 3);
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
transferWorkflow('Workflow in Home project', INSTANCE_MEMBERS[0].email);
clearNotifications();
workflowsPage.getters.workflowCards().should('have.length', 2);
});
it('should move the credential to expected projects', () => {
cy.intercept('GET', /\/rest\/(workflows|credentials).*/).as('getResources');
cy.signinAsOwner();
cy.visit(workflowsPage.url);
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 1);
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.contains('button', 'Move credential')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect().find('li').should('have.length', 5);
getVisibleSelect().find('li').filter(':contains("Project 2")').click();
projects.getResourceMoveModal().contains('button', 'Move credential').click();
clearNotifications();
cy.wait('@getResources');
credentialsPage.getters.credentialCards().should('not.have.length');
cy.log('Move the credential from Project 2 to admin user');
projects.getMenuItems().last().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 2);
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.contains('button', 'Move credential')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect().find('li').should('have.length', 5);
getVisibleSelect().find('li').filter(`:contains("${INSTANCE_ADMIN.email}")`).click();
projects.getResourceMoveModal().contains('button', 'Move credential').click();
clearNotifications();
cy.wait('@getResources');
credentialsPage.getters.credentialCards().should('have.length', 1);
cy.log('Move the credential from admin user back to instance owner');
projects.getHomeButton().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 3);
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.contains('button', 'Move credential')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect().find('li').should('have.length', 5);
getVisibleSelect().find('li').filter(`:contains("${INSTANCE_OWNER.email}")`).click();
projects.getResourceMoveModal().contains('button', 'Move credential').click();
clearNotifications();
cy.wait('@getResources');
credentialsPage.getters.credentialCards().should('have.length', 3);
credentialsPage.getters
.credentialCards()
.filter(':contains("Personal")')
.should('have.length', 2);
cy.log('Move the credential from admin user back to its original project (Project 1)');
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.contains('button', 'Move credential')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect().find('li').should('have.length', 5);
getVisibleSelect().find('li').filter(':contains("Project 1")').click();
projects.getResourceMoveModal().contains('button', 'Move credential').click();
clearNotifications();
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters
.credentialCards()
.filter(':contains("Credential in Project 1")')
.should('have.length', 1);
});
});
});

View File

@ -1,244 +0,0 @@
import { WorkflowPage, NDV } from '../../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
// Update is debounced in editors, so adding typing delay to catch up
const TYPING_DELAY = 100;
describe('Editors', () => {
beforeEach(() => {
workflowPage.actions.visit();
});
describe('SQL Editor', () => {
it('should preserve changes when opening-closing Postgres node', () => {
workflowPage.actions.addInitialNodeToCanvas('Postgres', {
action: 'Execute a SQL query',
keepNdvOpen: true,
});
ndv.getters
.sqlEditorContainer()
.click()
.find('.cm-content')
.type('SELECT * FROM `testTable`', { delay: TYPING_DELAY })
.type('{esc}');
ndv.actions.close();
workflowPage.actions.openNode('Execute a SQL query');
ndv.getters
.sqlEditorContainer()
.find('.cm-content')
.type('{end} LIMIT 10', { delay: TYPING_DELAY })
.type('{esc}');
ndv.actions.close();
workflowPage.actions.openNode('Execute a SQL query');
ndv.getters.sqlEditorContainer().should('contain', 'SELECT * FROM `testTable` LIMIT 10');
});
it('should update expression output dropdown as the query is edited', () => {
workflowPage.actions.addInitialNodeToCanvas('MySQL', {
action: 'Execute a SQL query',
});
ndv.actions.close();
workflowPage.actions.openNode('When clicking ‘Execute workflow’');
ndv.actions.setPinnedData([{ table: 'test_table' }]);
ndv.actions.close();
workflowPage.actions.openNode('Execute a SQL query');
ndv.getters
.sqlEditorContainer()
.find('.cm-content')
// }} is inserted automatically by bracket matching
.type('SELECT * FROM {{ $json.table', { parseSpecialCharSequences: false });
workflowPage.getters
.inlineExpressionEditorOutput()
.should('have.text', 'SELECT * FROM test_table');
});
it('should not push NDV header out with a lot of code in Postgres editor', () => {
workflowPage.actions.addInitialNodeToCanvas('Postgres', {
action: 'Execute a SQL query',
keepNdvOpen: true,
});
cy.fixture('Dummy_javascript.txt').then((code) => {
ndv.getters.sqlEditorContainer().find('.cm-content').paste(code);
});
ndv.getters.nodeExecuteButton().should('be.visible');
});
it('should not push NDV header out with a lot of code in MySQL editor', () => {
workflowPage.actions.addInitialNodeToCanvas('MySQL', {
action: 'Execute a SQL query',
keepNdvOpen: true,
});
cy.fixture('Dummy_javascript.txt').then((code) => {
ndv.getters.sqlEditorContainer().find('.cm-content').paste(code);
});
ndv.getters.nodeExecuteButton().should('be.visible');
});
it('should not trigger dirty flag if nothing is changed', () => {
workflowPage.actions.addInitialNodeToCanvas('Postgres', {
action: 'Execute a SQL query',
keepNdvOpen: true,
});
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.getters.isWorkflowSaved();
workflowPage.actions.openNode('Execute a SQL query');
ndv.actions.close();
// Workflow should still be saved
workflowPage.getters.isWorkflowSaved();
});
it('should trigger dirty flag if query is updated', () => {
workflowPage.actions.addInitialNodeToCanvas('Postgres', {
action: 'Execute a SQL query',
keepNdvOpen: true,
});
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.getters.isWorkflowSaved();
workflowPage.actions.openNode('Execute a SQL query');
ndv.getters
.sqlEditorContainer()
.click()
.find('.cm-content')
.type('SELECT * FROM `testTable`', { delay: TYPING_DELAY })
.type('{esc}');
ndv.actions.close();
workflowPage.getters.isWorkflowSaved().should('not.be.true');
});
it('should allow switching between SQL editors in connected nodes', () => {
workflowPage.actions.addInitialNodeToCanvas('Postgres', {
action: 'Execute a SQL query',
keepNdvOpen: true,
});
ndv.getters
.sqlEditorContainer()
.click()
.find('.cm-content')
.paste('SELECT * FROM `firstTable`');
ndv.actions.close();
workflowPage.actions.addNodeToCanvas('Postgres', true, true, 'Execute a SQL query');
ndv.getters
.sqlEditorContainer()
.click()
.find('.cm-content')
.paste('SELECT * FROM `secondTable`');
ndv.actions.close();
workflowPage.actions.openNode('Execute a SQL query');
ndv.actions.clickFloatingNode('Execute a SQL query1');
ndv.getters
.sqlEditorContainer()
.find('.cm-content')
.should('have.text', 'SELECT * FROM `secondTable`');
ndv.actions.clickFloatingNode('Execute a SQL query');
ndv.getters
.sqlEditorContainer()
.find('.cm-content')
.should('have.text', 'SELECT * FROM `firstTable`');
});
});
describe('HTML Editor', () => {
// Closing tags will be added by the editor
const TEST_ELEMENT_H1 = '<h1>Test';
const TEST_ELEMENT_P = '<p>Test';
it('should preserve changes when opening-closing HTML node', () => {
workflowPage.actions.addInitialNodeToCanvas('HTML', {
action: 'Generate HTML template',
keepNdvOpen: true,
});
ndv.getters
.htmlEditorContainer()
.click()
.find('.cm-content')
.type(`{selectall}${TEST_ELEMENT_H1}`, { delay: TYPING_DELAY, force: true })
.type('{esc}');
ndv.actions.close();
workflowPage.actions.openNode('HTML');
ndv.getters
.htmlEditorContainer()
.find('.cm-content')
.type(`{end}${TEST_ELEMENT_P}`, { delay: TYPING_DELAY, force: true })
.type('{esc}');
ndv.actions.close();
workflowPage.actions.openNode('HTML');
ndv.getters.htmlEditorContainer().should('contain', TEST_ELEMENT_H1);
ndv.getters.htmlEditorContainer().should('contain', TEST_ELEMENT_P);
});
it('should not trigger dirty flag if nothing is changed', () => {
workflowPage.actions.addInitialNodeToCanvas('HTML', {
action: 'Generate HTML template',
keepNdvOpen: true,
});
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.getters.isWorkflowSaved();
workflowPage.actions.openNode('HTML');
ndv.actions.close();
// Workflow should still be saved
workflowPage.getters.isWorkflowSaved();
});
it('should trigger dirty flag if query is updated', () => {
workflowPage.actions.addInitialNodeToCanvas('HTML', {
action: 'Generate HTML template',
keepNdvOpen: true,
});
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.getters.isWorkflowSaved();
workflowPage.actions.openNode('HTML');
ndv.getters
.htmlEditorContainer()
.click()
.find('.cm-content')
.type(`{selectall}${TEST_ELEMENT_H1}`, { delay: TYPING_DELAY, force: true })
.type('{esc}');
ndv.actions.close();
workflowPage.getters.isWorkflowSaved().should('not.be.true');
});
it('should allow switching between HTML editors in connected nodes', () => {
workflowPage.actions.addInitialNodeToCanvas('HTML', {
action: 'Generate HTML template',
keepNdvOpen: true,
});
ndv.getters
.htmlEditorContainer()
.click()
.find('.cm-content')
.type('{selectall}')
.paste('<div>First</div>');
ndv.actions.close();
workflowPage.actions.addNodeToCanvas('HTML', true, true, 'Generate HTML template');
ndv.getters
.htmlEditorContainer()
.click()
.find('.cm-content')
.type('{selectall}')
.paste('<div>Second</div>');
ndv.actions.close();
workflowPage.actions.openNode('HTML');
ndv.actions.clickFloatingNode('HTML1');
ndv.getters
.htmlEditorContainer()
.find('.cm-content')
.should('have.text', '<div>Second</div>');
ndv.actions.clickFloatingNode('HTML');
ndv.getters.htmlEditorContainer().find('.cm-content').should('have.text', '<div>First</div>');
});
});
});

View File

@ -1,86 +0,0 @@
import { NDV, WorkflowPage } from '../../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('ADO-2362 ADO-2350 NDV Prevent clipping long parameters and scrolling to expression', () => {
it('should show last parameters and open at scroll top of parameters', () => {
workflowPage.actions.visit();
cy.createFixtureWorkflow('Test-workflow-with-long-parameters.json');
workflowPage.actions.openNode('Schedule Trigger');
ndv.getters.inlineExpressionEditorInput().should('be.visible');
ndv.actions.close();
workflowPage.actions.openNode('Edit Fields1');
// first parameter should be visible
ndv.getters.inputLabel().eq(0).should('include.text', 'Mode');
ndv.getters.inputLabel().eq(0).should('be.visible');
ndv.getters.inlineExpressionEditorInput().should('have.length', 2);
// last parameter in view should be visible
ndv.getters.inlineExpressionEditorInput().eq(0).should('have.text', 'should be visible!');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// next parameter in view should not be visible
ndv.getters.inlineExpressionEditorInput().eq(1).should('have.text', 'not visible');
ndv.getters.inlineExpressionEditorInput().eq(1).should('be.visible');
ndv.actions.close();
workflowPage.actions.openNode('Schedule Trigger');
// first parameter (notice) should be visible
ndv.getters.nthParam(0).should('include.text', 'This workflow will run on the schedule ');
ndv.getters.inputLabel().eq(0).should('be.visible');
ndv.getters.inlineExpressionEditorInput().should('have.length', 2);
// last parameter in view should be visible
ndv.getters.inlineExpressionEditorInput().eq(0).should('have.text', 'should be visible');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// next parameter in view should not be visible
ndv.getters.inlineExpressionEditorInput().eq(1).should('have.text', 'not visible');
ndv.getters.inlineExpressionEditorInput().eq(1).should('not.be.visible');
ndv.actions.close();
workflowPage.actions.openNode('Slack');
// first field (credentials) should be visible
ndv.getters.nodeCredentialsLabel().should('be.visible');
// last parameter in view should be visible
ndv.getters.inlineExpressionEditorInput().eq(0).should('have.text', 'should be visible');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// next parameter in view should not be visible
ndv.getters.inlineExpressionEditorInput().eq(1).should('have.text', 'not visible');
ndv.getters.inlineExpressionEditorInput().eq(1).should('not.be.visible');
});
it('NODE-1272 ensure expressions scrolled to top, not middle', () => {
workflowPage.actions.visit();
cy.createFixtureWorkflow('Test-workflow-with-long-parameters.json');
workflowPage.actions.openNode('With long expression');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// should be scrolled at top
ndv.getters
.inlineExpressionEditorInput()
.eq(0)
.find('.cm-line')
.eq(0)
.should('have.text', '1 visible!');
ndv.getters.inlineExpressionEditorInput().eq(0).find('.cm-line').eq(0).should('be.visible');
ndv.getters
.inlineExpressionEditorInput()
.eq(0)
.find('.cm-line')
.eq(6)
.should('have.text', '7 not visible!');
ndv.getters.inlineExpressionEditorInput().eq(0).find('.cm-line').eq(6).should('not.be.visible');
});
});

View File

@ -1,121 +0,0 @@
import { EXECUTE_WORKFLOW_NODE_NAME } from '../../constants';
import { WorkflowPage as WorkflowPageClass, NDV } from '../../pages';
import { getVisiblePopper } from '../../utils';
const workflowPage = new WorkflowPageClass();
const ndv = new NDV();
describe('Workflow Selector Parameter', () => {
beforeEach(() => {
cy.resetDatabase();
cy.signinAsOwner();
['Get_Weather', 'Search_DB'].forEach((workflowName) => {
workflowPage.actions.visit();
cy.createFixtureWorkflow(`Test_Subworkflow_${workflowName}.json`);
workflowPage.actions.setWorkflowName(workflowName);
});
workflowPage.actions.visit();
workflowPage.actions.addInitialNodeToCanvas(EXECUTE_WORKFLOW_NODE_NAME, {
keepNdvOpen: true,
action: 'Execute A Sub Workflow',
});
});
it('should render sub-workflows list', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
getVisiblePopper()
.should('have.length', 1)
.findChildByTestId('rlc-item')
.should('have.length', 2);
getVisiblePopper().findChildByTestId('rlc-item-add-resource').should('have.length', 1);
});
it('should show required parameter warning', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
ndv.getters.parameterInputIssues('workflowId').should('exist');
});
it('should filter sub-workflows list', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
ndv.getters.resourceLocatorSearch('workflowId').type('Weather');
getVisiblePopper()
.should('have.length', 1)
.findChildByTestId('rlc-item')
.should('have.length', 1)
.eq(0)
.click();
ndv.getters
.resourceLocatorInput('workflowId')
.find('input')
.should('have.value', 'Get_Weather');
});
it('should render sub-workflow links correctly', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
getVisiblePopper().findChildByTestId('rlc-item').eq(1).click();
ndv.getters.resourceLocatorInput('workflowId').find('a').should('exist');
cy.getByTestId('radio-button-expression').eq(1).click();
ndv.getters.resourceLocatorInput('workflowId').find('a').should('not.exist');
});
it('should switch to ID mode on expression', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
getVisiblePopper().findChildByTestId('rlc-item').eq(1).click();
ndv.getters
.resourceLocatorModeSelector('workflowId')
.find('input')
.should('have.value', 'From list');
cy.getByTestId('radio-button-expression').eq(1).click();
ndv.getters
.resourceLocatorModeSelector('workflowId')
.find('input')
.should('have.value', 'By ID');
});
it('should render add resource option and redirect to the correct route when clicked', () => {
cy.window().then((win) => {
cy.stub(win, 'open').as('windowOpen');
});
cy.intercept('POST', '/rest/workflows*').as('createSubworkflow');
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
getVisiblePopper().findChildByTestId('rlc-item-add-resource').eq(0).should('exist');
getVisiblePopper()
.findChildByTestId('rlc-item-add-resource')
.eq(0)
.find('span')
.should('contain.text', 'Create a'); // Due to some inconsistency we're sometimes in a project and sometimes not, this covers both cases
getVisiblePopper().findChildByTestId('rlc-item-add-resource').eq(0).click();
cy.wait('@createSubworkflow').then((interception) => {
expect(interception.request.body).to.have.property('name').that.includes('Sub-Workflow');
expect(interception.request.body.nodes).to.be.an('array');
expect(interception.request.body.nodes).to.have.length(2);
expect(interception.request.body.nodes[0]).to.have.property(
'name',
'When Executed by Another Workflow',
);
expect(interception.request.body.nodes[1]).to.have.property(
'name',
'Replace me with your logic',
);
});
cy.get('@windowOpen').should('be.calledWithMatch', /\/workflow\/.+/);
});
});

View File

@ -1,249 +0,0 @@
import {
addItemToFixedCollection,
assertNodeOutputHintExists,
clickExecuteNode,
clickGetBackToCanvas,
getExecuteNodeButton,
getOutputTableHeaders,
getParameterInputByName,
populateFixedCollection,
selectResourceLocatorItem,
selectResourceLocatorAddResourceItem,
typeIntoFixedCollectionItem,
clickWorkflowCardContent,
assertOutputTableContent,
populateMapperFields,
getNodeRunInfoStale,
assertNodeOutputErrorMessageExists,
checkParameterCheckboxInputByName,
uncheckParameterCheckboxInputByName,
} from '../../composables/ndv';
import {
clickExecuteWorkflowButton,
clickZoomToFit,
navigateToNewWorkflowPage,
openNode,
pasteWorkflow,
saveWorkflowOnButtonClick,
} from '../../composables/workflow';
import { visitWorkflowsPage } from '../../composables/workflowsPage';
import SUB_WORKFLOW_INPUTS from '../../fixtures/Test_Subworkflow-Inputs.json';
import { errorToast, successToast } from '../../pages/notifications';
import { getVisiblePopper } from '../../utils';
const DEFAULT_WORKFLOW_NAME = 'My workflow';
const DEFAULT_SUBWORKFLOW_NAME_1 = 'My Sub-Workflow 1';
const DEFAULT_SUBWORKFLOW_NAME_2 = 'My Sub-Workflow 2';
const EXAMPLE_FIELDS = [
['aNumber', 'Number'],
['aString', 'String'],
['aArray', 'Array'],
['aObject', 'Object'],
['aAny', 'Allow Any Type'],
// bool last because it's a switch instead of a normal inputField so we'll skip it for some cases
['aBool', 'Boolean'],
] as const;
type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object';
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Sub-workflow creation and typed usage', () => {
beforeEach(() => {
navigateToNewWorkflowPage();
pasteWorkflow(SUB_WORKFLOW_INPUTS);
saveWorkflowOnButtonClick();
clickZoomToFit();
openNode('Execute Workflow');
let openedUrl = '';
// Prevent sub-workflow from opening in new window
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
openedUrl = url;
});
});
selectResourceLocatorAddResourceItem('workflowId', 'Create a');
cy.then(() => cy.visit(openedUrl));
// **************************
// NAVIGATE TO CHILD WORKFLOW
// **************************
// Close NDV before opening the node creator
clickGetBackToCanvas();
openNode('When Executed by Another Workflow');
});
it('works with type-checked values', () => {
populateFixedCollection(EXAMPLE_FIELDS, 'workflowInputs', 1);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
EXAMPLE_FIELDS.map((f) => f[0]),
);
const values = [
'-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it
...EXAMPLE_FIELDS.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // the `}}` at the end are added automatically
];
// this matches with the pinned data provided in the fixture
populateMapperFields(values.map((x, i) => [EXAMPLE_FIELDS[i][0], x]));
clickExecuteNode();
const expected = [
['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'],
['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'],
];
assertOutputTableContent(expected);
// Test the type-checking options
populateMapperFields([['aString', '{selectAll}{backspace}{{}{{} 5']]);
getNodeRunInfoStale().should('exist');
clickExecuteNode();
assertNodeOutputErrorMessageExists();
// attemptToConvertTypes enabled
checkParameterCheckboxInputByName('attemptToConvertTypes');
getNodeRunInfoStale().should('exist');
clickExecuteNode();
const expected2 = [
['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'],
['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'],
];
assertOutputTableContent(expected2);
// disabled again
uncheckParameterCheckboxInputByName('attemptToConvertTypes');
getNodeRunInfoStale().should('exist');
clickExecuteNode();
assertNodeOutputErrorMessageExists();
});
it('works with Fields input source, and can then be changed to JSON input source', () => {
assertNodeOutputHintExists();
populateFixedCollection(EXAMPLE_FIELDS, 'workflowInputs', 1);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
EXAMPLE_FIELDS.map((f) => f[0]),
);
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
selectResourceLocatorAddResourceItem('workflowId', 'Create a');
openNode('When Executed by Another Workflow');
getParameterInputByName('inputSource').click();
getVisiblePopper()
.getByTestId('parameter-input')
.eq(0)
.type('Using JSON Example{downArrow}{enter}');
const exampleJson =
'{{}' + EXAMPLE_FIELDS.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}';
getParameterInputByName('jsonExample')
.find('.cm-line')
.eq(0)
.type(`{selectAll}{backspace}${exampleJson}{enter}`);
// first one doesn't work for some reason, might need to wait for something?
clickExecuteNode();
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_2,
2,
EXAMPLE_FIELDS.map((f) => f[0]),
);
assertOutputTableContent([
['[null]', '[null]', '[null]', '[null]', '[null]', 'true'],
['[null]', '[null]', '[null]', '[null]', '[null]', 'true'],
]);
clickExecuteNode();
});
});
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('should show node issue when no fields are defined in manual mode', () => {
getExecuteNodeButton().should('be.disabled');
clickGetBackToCanvas();
// Executing the workflow should show an error toast
clickExecuteWorkflowButton();
errorToast().should('contain', 'The workflow has issues');
openNode('When Executed by Another Workflow');
// Add a field to the workflowInputs fixedCollection
addItemToFixedCollection('workflowInputs');
typeIntoFixedCollectionItem('workflowInputs', 0, 'test');
// Executing the workflow should not show error now
clickGetBackToCanvas();
clickExecuteWorkflowButton();
successToast().should('contain', 'Workflow executed successfully');
});
});
// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields
// It then navigates back to the parent and validates the outputPanel matches our changes
function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) {
clickExecuteNode();
// + 1 to account for formatting-only column
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
clickGetBackToCanvas();
saveWorkflowOnButtonClick();
visitWorkflowsPage();
clickWorkflowCardContent(DEFAULT_WORKFLOW_NAME);
openNode('Execute Workflow');
// Note that outside of e2e tests this will be pre-selected correctly.
// Due to our workaround to remain in the same tab we need to select the correct tab manually
selectResourceLocatorItem('workflowId', offset - 1, targetChild);
clickExecuteNode();
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
}
function makeExample(type: TypeField) {
switch (type) {
case 'String':
return '"example"';
case 'Number':
return '42';
case 'Boolean':
return 'true';
case 'Array':
return '["example", 123, null]';
case 'Object':
return '{{}"example": [123]}';
case 'Allow Any Type':
return 'null';
}
}

View File

@ -1,694 +0,0 @@
import {
createFolderFromCardActions,
createFolderFromListDropdown,
createFolderFromListHeaderButton,
createFolderFromProjectHeader,
createFolderInsideFolder,
createNewProject,
createWorkflowFromEmptyState,
createWorkflowFromListDropdown,
createWorkflowFromProjectHeader,
deleteAndTransferFolderContentsFromCardDropdown,
deleteAndTransferFolderContentsFromListDropdown,
deleteEmptyFolderFromCardDropdown,
deleteEmptyFolderFromListDropdown,
deleteFolderWithContentsFromCardDropdown,
deleteFolderWithContentsFromListDropdown,
dragAndDropToFolder,
dragAndDropToProjectRoot,
duplicateWorkflowFromCardActions,
duplicateWorkflowFromWorkflowPage,
getAddResourceDropdown,
getCanvasBreadcrumbs,
getCurrentBreadcrumbText,
getFolderCard,
getFolderCardActionItem,
getFolderCardActionToggle,
getFolderCards,
getFolderEmptyState,
getHomeProjectBreadcrumb,
getListBreadcrumbItem,
getListBreadcrumbs,
getMainBreadcrumbsEllipsis,
getMainBreadcrumbsEllipsisMenuItems,
getNewFolderModalErrorMessage,
getNewFolderNameInput,
getOverviewMenuItem,
getPersonalProjectMenuItem,
getProjectEmptyState,
getProjectMenuItem,
getProjectTab,
getVisibleListBreadcrumbs,
getWorkflowCard,
getWorkflowCardBreadcrumbs,
getWorkflowCardBreadcrumbsEllipsis,
getWorkflowCards,
goToPersonalProject,
moveFolderFromFolderCardActions,
moveFolderFromListActions,
moveWorkflowToFolder,
renameFolderFromCardActions,
renameFolderFromListActions,
} from '../../composables/folders';
import { expandSidebar } from '../../composables/sidebar';
import { visitWorkflowsPage } from '../../composables/workflowsPage';
import { successToast } from '../../pages/notifications';
describe('Folders', () => {
before(() => {
cy.resetDatabase();
cy.enableFeature('sharing');
cy.enableFeature('folders');
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
cy.changeQuota('maxTeamProjects', -1);
});
beforeEach(() => {
visitWorkflowsPage();
});
describe('Create and navigate folders', () => {
it('should create folder from the project header', () => {
// 1. In project root
getPersonalProjectMenuItem().click();
createFolderFromProjectHeader('My Folder');
getFolderCards().should('have.length.greaterThan', 0);
// Clicking on the success toast should navigate to the folder
successToast().find('a').click();
getCurrentBreadcrumbText().should('equal', 'My Folder');
// 2. In a folder
createFolderFromListHeaderButton('My Folder 2');
getFolderCard('My Folder 2').should('exist');
});
it('should not allow illegal folder names', () => {
// Validation logic is thoroughly tested in unit tests
// Here we just make sure everything is working in the full UI
const ILLEGAL_CHARACTERS_NAME = 'hello[';
const ONLY_DOTS_NAME = '...';
const REGULAR_NAME = 'My Folder';
getPersonalProjectMenuItem().click();
getAddResourceDropdown().click();
cy.getByTestId('action-folder').click();
getNewFolderNameInput().type(ILLEGAL_CHARACTERS_NAME, { delay: 50 });
getNewFolderModalErrorMessage().should(
'contain.text',
'Folder name cannot contain the following characters',
);
getNewFolderNameInput().clear();
getNewFolderNameInput().type(ONLY_DOTS_NAME, { delay: 50 });
getNewFolderModalErrorMessage().should(
'contain.text',
'Folder name cannot contain only dots',
);
getNewFolderNameInput().clear();
getNewFolderModalErrorMessage().should('contain.text', 'Folder name cannot be empty');
getNewFolderNameInput().type(REGULAR_NAME, { delay: 50 });
getNewFolderModalErrorMessage().should('not.exist');
});
it('should create folder from the list header button', () => {
goToPersonalProject();
// First create a folder so list appears
createFolderFromProjectHeader('Test 2');
createFolderFromListHeaderButton('My Folder 2');
getFolderCards().should('have.length.greaterThan', 0);
// Clicking on the success toast should navigate to the folder
successToast().contains('My Folder 2').find('a').contains('Open folder').click();
getCurrentBreadcrumbText().should('equal', 'My Folder 2');
});
it('should create folder from the list header dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Created from list dropdown');
getFolderCard('Created from list dropdown').should('exist');
getFolderCard('Created from list dropdown').click();
createFolderFromListDropdown('Child Folder');
successToast().should('exist');
getFolderCard('Child Folder').should('exist');
});
it('should create folder from the card dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Created from card dropdown');
getFolderCard('Created from card dropdown').should('exist');
createFolderFromCardActions('Created from card dropdown', 'Child Folder');
successToast().should('exist');
// Should be automatically navigated to the new folder
getFolderCard('Child Folder').should('exist');
getCurrentBreadcrumbText().should('equal', 'Created from card dropdown');
});
it('should navigate folders using breadcrumbs and dropdown menu', () => {
goToPersonalProject();
createFolderFromProjectHeader('Navigate Test');
// Open folder using menu item
getFolderCardActionToggle('Navigate Test').click();
getFolderCardActionItem('Navigate Test', 'open').click();
getCurrentBreadcrumbText().should('equal', 'Navigate Test');
// Create new child folder and navigate to it
createFolderFromListHeaderButton('Child Folder');
getFolderCard('Child Folder').should('exist');
getFolderCard('Child Folder').click();
getCurrentBreadcrumbText().should('equal', 'Child Folder');
// Navigate back to parent folder using breadcrumbs
getVisibleListBreadcrumbs().contains('Navigate Test').click();
getCurrentBreadcrumbText().should('equal', 'Navigate Test');
// Go back to home project using breadcrumbs
getHomeProjectBreadcrumb().click();
getListBreadcrumbs().should('not.exist');
});
// Creates folders inside folders and also checks breadcrumbs
it('should create multiple levels of folders', () => {
goToPersonalProject();
createFolderFromProjectHeader('Multi-level Test');
createFolderInsideFolder('Child Folder', 'Multi-level Test');
// One level deep:
// - Breadcrumbs should only show home project and current folder
getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumbText().should('equal', 'Multi-level Test');
getFolderCard('Child Folder').should('exist');
createFolderInsideFolder('Child Folder 2', 'Child Folder');
// Two levels deep:
// - Breadcrumbs should also show parent folder, without hidden ellipsis
getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumbText().should('equal', 'Child Folder');
getVisibleListBreadcrumbs().should('have.length', 1);
getMainBreadcrumbsEllipsis().should('not.exist');
// Three levels deep:
// - Breadcrumbs should show parents up to the grandparent folder, with one hidden element
createFolderInsideFolder('Child Folder 3', 'Child Folder 2');
getVisibleListBreadcrumbs().should('have.length', 1);
getMainBreadcrumbsEllipsis().should('exist');
// Clicking on the ellipsis should show hidden element in main breadcrumbs
getMainBreadcrumbsEllipsis().click();
getMainBreadcrumbsEllipsisMenuItems().first().should('contain.text', 'Multi-level Test');
getMainBreadcrumbsEllipsis().click();
});
// Make sure breadcrumbs and folder card show correct info when landing straight on a folder page
it('should correctly render all elements when landing on a folder page', () => {
// Create a few levels of folders
goToPersonalProject();
createFolderFromProjectHeader('Landing Test');
createFolderInsideFolder('Child Folder', 'Landing Test');
createFolderInsideFolder('Child Folder 2', 'Child Folder');
createFolderInsideFolder('Child Folder 3', 'Child Folder 2');
// Reload page to simulate landing on a folder page
cy.reload();
// Main list breadcrumbs should show home project, parent, grandparent, with one hidden element
getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumbText().should('equal', 'Child Folder 2');
getVisibleListBreadcrumbs().should('have.length', 1);
getVisibleListBreadcrumbs().first().should('contain.text', 'Child Folder');
getMainBreadcrumbsEllipsis().should('exist');
getMainBreadcrumbsEllipsis().click();
getMainBreadcrumbsEllipsisMenuItems().first().should('contain.text', 'Landing Test');
// Should load child folder card
getFolderCard('Child Folder 3').should('exist');
});
it('should show folders only in projects', () => {
// No folder cards should be shown in the overview page
expandSidebar();
getOverviewMenuItem().click();
getFolderCards().should('not.exist');
// Option to create folders should not be available in the dropdown
getAddResourceDropdown().click();
cy.getByTestId('action-folder').should('not.exist');
// In personal, we should see previously created folders
getPersonalProjectMenuItem().click();
getAddResourceDropdown().click();
cy.getByTestId('action-folder').should('exist');
createFolderFromProjectHeader('Personal Folder');
getFolderCards().should('exist');
// Create folder option should not be available on credentials tab
getProjectTab('ProjectsCredentials').click();
getAddResourceDropdown().click();
cy.getByTestId('action-folder').should('not.exist');
});
});
describe('Empty State', () => {
it('should show project empty state when no folders exist', () => {
expandSidebar();
createNewProject('Test empty project', { openAfterCreate: true });
getProjectEmptyState().should('exist');
});
it('should toggle folder empty state correctly', () => {
expandSidebar();
createNewProject('Test empty folder', { openAfterCreate: true });
createFolderFromProjectHeader('My Folder');
getProjectEmptyState().should('not.exist');
getFolderCard('My Folder').should('exist');
getFolderCard('My Folder').click();
getFolderEmptyState().should('exist');
// Create a new workflow from the empty state
createWorkflowFromEmptyState('My Workflow');
// Toast should inform that the workflow was created in the folder
successToast().should(
'contain.text',
'Workflow successfully created in "Test empty folder", within "My Folder"',
);
// Go back to the folder
getProjectMenuItem('Test empty folder').click();
getFolderCard('My Folder').should('exist');
getFolderCard('My Folder').click();
// Should not show empty state anymore
getFolderEmptyState().should('not.exist');
getWorkflowCards().should('have.length.greaterThan', 0);
// Also when filtering and there are no results, empty state CTA should not show
cy.getByTestId('resources-list-search').type('non-existing', { delay: 20 });
getWorkflowCards().should('not.exist');
getFolderEmptyState().should('not.exist');
// But there should be a message saying that no results were found
cy.getByTestId('resources-list-empty').should('exist');
});
});
describe('Create workflows inside folders', () => {
it('should create workflows in folders in all supported ways', () => {
goToPersonalProject();
createFolderFromProjectHeader('Workflows go here');
// 1. From empty state
getFolderCard('Workflows go here').should('exist').click();
createWorkflowFromEmptyState('Created from empty state');
goToPersonalProject();
getFolderCard('Workflows go here').click();
getWorkflowCard('Created from empty state').should('exist');
// 2. From the project header
createWorkflowFromProjectHeader('Workflows go here', 'Created from project header');
goToPersonalProject();
getFolderCard('Workflows go here').click();
getWorkflowCard('Created from project header').should('exist');
// 3. From list breadcrumbs
createWorkflowFromListDropdown('Created from list breadcrumbs');
goToPersonalProject();
getFolderCard('Workflows go here').click();
getWorkflowCard('Created from list breadcrumbs').should('exist');
});
it('should show new workflow breadcrumbs correctly', () => {
goToPersonalProject();
createFolderFromProjectHeader('Workflow breadcrumbs test');
getFolderCard('Workflow breadcrumbs test').should('exist').click();
getFolderEmptyState().find('button').contains('Create Workflow').click();
// Should show breadcrumbs before and after saving new workflow
getCanvasBreadcrumbs().should('exist');
getCanvasBreadcrumbs().findChildByTestId('home-project').should('contain.text', 'Personal');
getCanvasBreadcrumbs().find('li[data-test-id="breadcrumbs-item"]').should('have.length', 1);
// Save workflow and reload
cy.getByTestId('workflow-save-button').click();
cy.reload();
// Should still show the same breadcrumbs
getCanvasBreadcrumbs().should('exist');
getCanvasBreadcrumbs().findChildByTestId('home-project').should('contain.text', 'Personal');
getCanvasBreadcrumbs().find('li[data-test-id="breadcrumbs-item"]').should('have.length', 1);
});
});
describe('Rename and delete folders', () => {
it('should rename folder from main dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Rename Me');
getFolderCard('Rename Me').should('exist');
renameFolderFromListActions('Rename Me', 'Renamed');
getCurrentBreadcrumbText().should('equal', 'Renamed');
});
it('should rename folder from card dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Rename Me 2');
renameFolderFromCardActions('Rename Me 2', 'Renamed 2');
getFolderCard('Renamed 2').should('exist');
});
it('should delete empty folder from card dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Delete Me');
getFolderCard('Delete Me').should('exist');
deleteEmptyFolderFromCardDropdown('Delete Me');
});
it('should delete empty folder from main dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Delete Me 2');
getFolderCard('Delete Me 2').should('exist');
deleteEmptyFolderFromListDropdown('Delete Me 2');
// Since we deleted the current folder, we should be back in the home project
getListBreadcrumbs().should('not.exist');
getPersonalProjectMenuItem().find('li').should('have.class', 'is-active');
});
it('should warn before deleting non-empty folder from list dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('I have children');
createFolderInsideFolder('Child 1', 'I have children');
deleteFolderWithContentsFromListDropdown('I have children');
// Since we deleted the current folder, we should be back in the home project
getListBreadcrumbs().should('not.exist');
getPersonalProjectMenuItem().find('li').should('have.class', 'is-active');
});
it('should warn before deleting non-empty folder from card dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('I also have family');
createFolderInsideFolder('Child 1', 'I also have family');
// Back to home
getHomeProjectBreadcrumb().click();
getFolderCard('I also have family').should('exist');
deleteFolderWithContentsFromCardDropdown('I also have family');
});
it('should transfer contents when deleting non-empty folder - from card dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Move my contents');
createFolderFromProjectHeader('Destination');
createFolderInsideFolder('Child 1', 'Move my contents');
getHomeProjectBreadcrumb().click();
getFolderCard('Move my contents').should('exist');
deleteAndTransferFolderContentsFromCardDropdown('Move my contents', 'Destination');
getFolderCard('Destination').click();
// Should show the contents of the moved folder
getFolderCard('Child 1').should('exist');
});
it('should transfer contents when deleting non-empty folder - from list breadcrumbs', () => {
goToPersonalProject();
createFolderFromProjectHeader('Move me too');
createFolderFromProjectHeader('Destination 2');
createFolderInsideFolder('Child 1', 'Move me too');
deleteAndTransferFolderContentsFromListDropdown('Destination 2');
getFolderCard('Destination').click();
// Should show the contents of the moved folder
getFolderCard('Child 1').should('exist');
});
});
describe('Move folders and workflows', () => {
it('should move empty folder to another folder - from folder card action', () => {
goToPersonalProject();
createFolderFromProjectHeader('Move me - I am empty');
createFolderFromProjectHeader('Destination 3');
moveFolderFromFolderCardActions('Move me - I am empty', 'Destination 3');
getFolderCard('Destination 3').click();
getFolderCard('Move me - I am empty').should('exist');
getFolderCard('Move me - I am empty').click();
getFolderEmptyState().should('exist');
successToast().should('contain.text', 'Move me - I am empty has been moved to Destination 3');
// Breadcrumbs should show the destination folder
getListBreadcrumbItem('Destination 3').should('exist');
});
it('should move folder with contents to another folder - from folder card action', () => {
goToPersonalProject();
createFolderFromProjectHeader('Move me - I have family');
createFolderFromProjectHeader('Destination 4');
// Create a workflow and a folder inside the folder
createFolderInsideFolder('Child 1', 'Move me - I have family');
createWorkflowFromProjectHeader('Move me - I have family');
goToPersonalProject();
// Move the folder
moveFolderFromFolderCardActions('Move me - I have family', 'Destination 4');
successToast().should(
'contain.text',
'Move me - I have family has been moved to Destination 4',
);
// Go to destination folder and check if contents are there
getFolderCard('Destination 4').click();
// Moved folder should be there
getFolderCard('Move me - I have family').should('exist').click();
// Both the workflow and the folder should be there
getFolderCards().should('have.length', 1);
getWorkflowCards().should('have.length', 1);
// Breadcrumbs should show the destination folder
getListBreadcrumbItem('Destination 4').should('exist');
});
it('should move empty folder to another folder - from list breadcrumbs', () => {
goToPersonalProject();
createFolderFromProjectHeader('Move me too - I am empty');
createFolderFromProjectHeader('Destination 5');
moveFolderFromListActions('Move me too - I am empty', 'Destination 5');
// Since we moved the current folder, we should be in the destination folder
getCurrentBreadcrumbText().should('equal', 'Destination 5');
});
it('should move folder with contents to another folder - from list dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Move me - I have family 2');
createFolderFromProjectHeader('Destination 6');
// Create a workflow and a folder inside the folder
createFolderInsideFolder('Child 1', 'Move me - I have family 2');
createWorkflowFromProjectHeader('Move me - I have family 2');
// Navigate back to folder
goToPersonalProject();
getFolderCard('Move me - I have family 2').should('exist');
// Move the folder
moveFolderFromListActions('Move me - I have family 2', 'Destination 6');
// Since we moved the current folder, we should be in the destination folder
getCurrentBreadcrumbText().should('equal', 'Destination 6');
// Moved folder should be there
getFolderCard('Move me - I have family 2').should('exist').click();
// After navigating to the moved folder, both the workflow and the folder should be there
getFolderCards().should('have.length', 1);
getWorkflowCards().should('have.length', 1);
// Breadcrumbs should show the destination folder
getListBreadcrumbItem('Destination 6').should('exist');
});
it('should move folder to project root - from folder card action', () => {
goToPersonalProject();
createFolderFromProjectHeader('Test parent');
createFolderInsideFolder('Move me to root', 'Test parent');
moveFolderFromFolderCardActions('Move me to root', 'No folder (project root)');
// Parent folder should be empty
getFolderEmptyState().should('exist');
// Child folder should be in the root
goToPersonalProject();
getFolderCard('Move me to root').should('exist');
// Navigate to the moved folder and check breadcrumbs
getFolderCard('Move me to root').click();
getHomeProjectBreadcrumb().should('contain.text', 'Personal');
getListBreadcrumbs().findChildByTestId('breadcrumbs-item').should('not.exist');
getCurrentBreadcrumbText().should('equal', 'Move me to root');
});
it('should move workflow from project root to folder', () => {
goToPersonalProject();
createWorkflowFromProjectHeader(undefined, 'Move me');
goToPersonalProject();
createFolderFromProjectHeader('Workflow destination');
moveWorkflowToFolder('Move me', 'Workflow destination');
successToast().should('contain.text', 'Move me has been moved to Workflow destination');
// Navigate to the destination folder
getFolderCard('Workflow destination').click();
// Moved workflow should be there
getWorkflowCards().should('have.length', 1);
getWorkflowCard('Move me').should('exist');
});
it('should move workflow to another folder', () => {
goToPersonalProject();
createFolderFromProjectHeader('Moving workflow from here');
createFolderFromProjectHeader('Moving workflow to here');
getFolderCard('Moving workflow from here').click();
createWorkflowFromProjectHeader(undefined, 'Move me');
goToPersonalProject();
getFolderCard('Moving workflow from here').click();
getWorkflowCard('Move me').should('exist');
moveWorkflowToFolder('Move me', 'Moving workflow to here');
// Now folder should be empty
getFolderEmptyState().should('exist');
// Navigate to the destination folder
getHomeProjectBreadcrumb().click();
getFolderCard('Moving workflow to here').click();
// Moved workflow should be there
getWorkflowCards().should('have.length', 1);
getWorkflowCard('Move me').should('exist');
});
});
describe('Card breadcrumbs', () => {
it('should correctly show workflow card breadcrumbs in overview page', () => {
expandSidebar();
createNewProject('Test card breadcrumbs', { openAfterCreate: true });
createFolderFromProjectHeader('Parent Folder');
createFolderInsideFolder('Child Folder', 'Parent Folder');
getFolderCard('Child Folder').click();
createFolderFromListHeaderButton('Child Folder 2');
getFolderCard('Child Folder 2').click();
createWorkflowFromEmptyState('Breadcrumbs Test');
// Go to overview page
getOverviewMenuItem().click();
getWorkflowCard('Breadcrumbs Test').should('exist');
getWorkflowCardBreadcrumbs('Breadcrumbs Test').should('exist');
getWorkflowCardBreadcrumbsEllipsis('Breadcrumbs Test').should('exist');
getWorkflowCardBreadcrumbsEllipsis('Breadcrumbs Test').realHover({ position: 'topLeft' });
cy.get('[role=tooltip]').should('exist');
cy.get('[role=tooltip]').should(
'contain.text',
'Test card breadcrumbs / Parent Folder / Child Folder / Child Folder 2',
);
});
it('should correctly toggle folder and workflow card breadcrumbs in projects and folders', () => {
expandSidebar();
createNewProject('Test nested search', { openAfterCreate: true });
createFolderFromProjectHeader('Parent Folder');
getFolderCard('Parent Folder').click();
createWorkflowFromEmptyState('Child - Workflow');
getProjectMenuItem('Test nested search').click();
createFolderInsideFolder('Child Folder', 'Parent Folder');
// Should not show breadcrumbs in the folder if there is no search term
cy.getByTestId('card-badge').should('not.exist');
// Back to project root
getHomeProjectBreadcrumb().click();
// Should not show breadcrumbs in the project if there is no search term
cy.getByTestId('card-badge').should('not.exist');
// Search for something
cy.getByTestId('resources-list-search').type('child', { delay: 20 });
// Both folder and workflow from child folder should be in the results - nested search works
getFolderCards().should('have.length', 1);
getWorkflowCards().should('have.length', 1);
// Card badges with breadcrumbs should be shown
getFolderCard('Child Folder').findChildByTestId('card-badge').should('exist');
getWorkflowCard('Child - Workflow').findChildByTestId('card-badge').should('exist');
});
});
describe('Duplicate workflows', () => {
beforeEach(() => {
// Prevent the duplicated workflow from opening in a new tab
cy.window().then((win) => {
cy.stub(win, 'open').as('open');
});
});
it('should duplicate workflow within root folder from personal projects', () => {
goToPersonalProject();
createWorkflowFromProjectHeader(undefined, 'Duplicate Me From Root');
goToPersonalProject();
duplicateWorkflowFromCardActions('Duplicate Me From Root', 'Duplicate Me From Root (Copy)');
getWorkflowCard('Duplicate Me From Root (Copy)').should('exist');
});
it('should duplicate workflow within a folder from personal projects', () => {
goToPersonalProject();
createFolderFromProjectHeader('Parent folder for duplication');
getFolderCard('Parent folder for duplication').click();
createWorkflowFromProjectHeader(
'Parent folder for duplication',
'Duplicate Me From Personal',
);
goToPersonalProject();
getFolderCard('Parent folder for duplication').click();
duplicateWorkflowFromCardActions(
'Duplicate Me From Personal',
'Duplicate Me From Personal (Copy)',
);
getWorkflowCard('Duplicate Me From Personal (Copy)').should('exist');
});
it('should duplicate workflow within a folder from overview', () => {
expandSidebar();
goToPersonalProject();
getFolderCard('Parent folder for duplication').click();
createWorkflowFromProjectHeader(
'Parent folder for duplication',
'Duplicate Me From Overview',
);
getOverviewMenuItem().click();
duplicateWorkflowFromCardActions(
'Duplicate Me From Overview',
'Duplicate Me From Overview (Copy)',
);
getWorkflowCard('Duplicate Me From Overview (Copy)').should('exist');
goToPersonalProject();
getFolderCard('Parent folder for duplication').click();
getWorkflowCard('Duplicate Me From Overview (Copy)').should('exist');
});
it('should duplicate workflow within a folder from workflow', () => {
goToPersonalProject();
createFolderFromProjectHeader('Parent folder for duplication');
getFolderCard('Parent folder for duplication').click();
createWorkflowFromProjectHeader(
'Parent folder for duplication',
'Duplicate Me From Workflow',
);
duplicateWorkflowFromWorkflowPage('Duplicate Me From Workflow (Copy)');
goToPersonalProject();
getFolderCard('Parent folder for duplication').click();
getWorkflowCard('Duplicate Me From Workflow (Copy)').should('exist');
});
});
describe('Drag and drop', () => {
it('should drag and drop folders into folders', () => {
const PROJECT_NAME = 'Drag and Drop Test';
const TARGET_NAME = 'Drag me';
const DESTINATION_NAME = 'Folder Destination';
expandSidebar();
createNewProject(PROJECT_NAME, { openAfterCreate: true });
createFolderFromProjectHeader(TARGET_NAME);
createFolderFromProjectHeader(DESTINATION_NAME);
dragAndDropToFolder(TARGET_NAME, DESTINATION_NAME);
successToast().should('contain.text', `${TARGET_NAME} has been moved to ${DESTINATION_NAME}`);
// Only one folder card should remain
getFolderCards().should('have.length', 1);
// Check folder in the destination
getFolderCard(DESTINATION_NAME).click();
getFolderCard(TARGET_NAME).should('exist');
});
it('should drag and drop folders into project root breadcrumb', () => {
const PROJECT_NAME = 'Drag to root test';
const TARGET_NAME = 'To Project root';
const PARENT_NAME = 'Parent Folder';
expandSidebar();
createNewProject(PROJECT_NAME, { openAfterCreate: true });
createFolderFromProjectHeader(PARENT_NAME);
createFolderInsideFolder(TARGET_NAME, PARENT_NAME);
dragAndDropToProjectRoot(TARGET_NAME);
// No folder cards should be shown in the parent folder
getFolderCards().should('not.exist');
successToast().should('contain.text', `${TARGET_NAME} has been moved to ${PROJECT_NAME}`);
// Check folder in the project root
getProjectMenuItem(PROJECT_NAME).click();
getFolderCard(TARGET_NAME).should('exist');
});
it('should drag and drop workflows into folders', () => {
const PROJECT_NAME = 'Drag and Drop WF Test';
const TARGET_NAME = 'Drag me - WF';
const DESTINATION_NAME = 'Workflow Destination';
expandSidebar();
createNewProject(PROJECT_NAME, { openAfterCreate: true });
createFolderFromProjectHeader(DESTINATION_NAME);
createWorkflowFromProjectHeader(undefined, TARGET_NAME);
getProjectMenuItem(PROJECT_NAME).click();
dragAndDropToFolder(TARGET_NAME, DESTINATION_NAME);
// No workflow cards should be shown in the project root
getWorkflowCards().should('not.exist');
successToast().should('contain.text', `${TARGET_NAME} has been moved to ${DESTINATION_NAME}`);
// Check workflow in the destination
getFolderCard(DESTINATION_NAME).click();
getWorkflowCard(TARGET_NAME).should('exist');
});
});
});

View File

@ -1,80 +0,0 @@
import {
clickContextMenuAction,
clickZoomToFit,
executeWorkflow,
getCanvasNodeByName,
getCanvasNodes,
hitUndo,
navigateToNewWorkflowPage,
openContextMenu,
pasteWorkflow,
saveWorkflowOnButtonClick,
selectRight,
} from '../../composables/workflow';
import SubworkflowExtractionFixture from '../../fixtures/Subworkflow-extraction-workflow.json';
import { clearAnyNotifications, successToast } from '../../pages/notifications';
const EDIT_FIELDS_NAMES = [
'Edit Fields0',
'Edit Fields1',
'Edit Fields2',
'Edit Fields3',
'Edit Fields4',
'Edit Fields5',
] as const;
function selectNode(nodeName: string) {
getCanvasNodeByName(nodeName).click();
}
function executeAndConfirmSuccess() {
executeWorkflow();
successToast().should('contain.text', 'Workflow executed successfully');
clearAnyNotifications();
}
describe('Subworkflow Extraction', () => {
beforeEach(() => {
navigateToNewWorkflowPage();
// this selects all nodes
pasteWorkflow(SubworkflowExtractionFixture);
saveWorkflowOnButtonClick();
getCanvasNodes().should('have.length', 7);
clickZoomToFit();
executeAndConfirmSuccess();
openContextMenu();
clickContextMenuAction('deselect_all');
});
describe('can extract a valid selection and still execute the workflow', () => {
it('should extract a node and succeed execution, and then undo and succeed executions', () => {
for (const name of EDIT_FIELDS_NAMES) {
selectNode(name);
openContextMenu(name);
clickContextMenuAction('extract_sub_workflow');
cy.getByTestId('submit-button').click();
executeAndConfirmSuccess();
}
for (const _ of EDIT_FIELDS_NAMES) {
hitUndo();
executeAndConfirmSuccess();
}
});
it('should extract all nodes besides trigger and succeed execution', () => {
selectNode(EDIT_FIELDS_NAMES[0]);
selectRight();
openContextMenu();
clickContextMenuAction('extract_sub_workflow');
cy.getByTestId('submit-button').click();
executeAndConfirmSuccess();
});
});
});

View File

@ -1,257 +0,0 @@
import { getCanvasNodes } from '../../composables/workflow';
import {
SCHEDULE_TRIGGER_NODE_NAME,
SET_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_DISPLAY_NAME,
} from '../../constants';
import { NDV } from '../../pages/ndv';
import { WorkflowPage as WorkflowPageClass } from '../../pages/workflow';
const WorkflowPage = new WorkflowPageClass();
const ndv = new NDV();
describe('Undo/Redo', () => {
beforeEach(() => {
WorkflowPage.actions.visit();
});
it('should undo/redo deleting node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_DISPLAY_NAME, {
method: 'right-click',
anchor: 'topLeft',
});
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should undo/redo deleting node using keyboard shortcut', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.getters.canvasNodeByName(CODE_NODE_DISPLAY_NAME).click();
cy.get('body').type('{backspace}');
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should undo/redo deleting node between two connected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(CODE_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.zoomToFit();
cy.get('body').type('{backspace}');
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 2);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
});
it('should undo/redo deleting whole workflow', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addCodeNodeToCanvas();
cy.get('body').type('{esc}');
cy.get('body').type('{esc}');
WorkflowPage.actions.hitDeleteAllNodes();
WorkflowPage.getters.canvasNodes().should('have.have.length', 0);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 0);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should undo/redo moving nodes', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.zoomToFit();
getCanvasNodes()
.last()
.then(($node) => {
const { x: x1, y: y1 } = $node[0].getBoundingClientRect();
cy.drag(getCanvasNodes().last(), [50, 150], {
realMouse: true,
abs: true,
});
getCanvasNodes()
.last()
.then(($node) => {
const { x: x2, y: y2 } = $node[0].getBoundingClientRect();
expect(x2).to.be.greaterThan(x1);
expect(y2).to.be.greaterThan(y1);
});
WorkflowPage.actions.hitUndo();
getCanvasNodes()
.last()
.then(($node) => {
const { x: x3, y: y3 } = $node[0].getBoundingClientRect();
expect(x3).to.equal(x1);
expect(y3).to.equal(y1);
});
WorkflowPage.actions.hitRedo();
getCanvasNodes()
.last()
.then(($node) => {
const { x: x4, y: y4 } = $node[0].getBoundingClientRect();
expect(x4).to.be.greaterThan(x1);
expect(y4).to.be.greaterThan(y1);
});
});
});
it('should undo/redo deleting a connection using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.deleteNodeBetweenNodes(SCHEDULE_TRIGGER_NODE_NAME, CODE_NODE_DISPLAY_NAME);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should undo/redo disabling a node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.disableNode(CODE_NODE_DISPLAY_NAME);
WorkflowPage.getters.disabledNodes().should('have.length', 1);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.disabledNodes().should('have.length', 0);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.disabledNodes().should('have.length', 1);
});
it('should undo/redo disabling a node using keyboard shortcut', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.getters.canvasNodes().last().click();
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 1);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.disabledNodes().should('have.length', 0);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.disabledNodes().should('have.length', 1);
});
it('should undo/redo disabling multiple nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addCodeNodeToCanvas();
cy.get('body').type('{esc}');
cy.get('body').type('{esc}');
WorkflowPage.actions.hitSelectAll();
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 2);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.disabledNodes().should('have.length', 0);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.disabledNodes().should('have.length', 2);
});
it('should undo/redo duplicating a node', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.duplicateNode(CODE_NODE_DISPLAY_NAME);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodes().should('have.length', 3);
});
it('should undo/redo pasting nodes', () => {
cy.fixture('Test_workflow-actions_paste-data.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
WorkflowPage.actions.zoomToFit();
WorkflowPage.getters.canvasNodes().should('have.have.length', 5);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 0);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 5);
});
});
it('should be able to copy and paste pinned data nodes in workflows with dynamic Switch node', () => {
cy.fixture('Test_workflow_form_switch.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
});
WorkflowPage.actions.zoomToFit();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')).should('have.length', 1);
cy.wait(1000); // Clipboard paste is throttled
cy.fixture('Test_workflow_form_switch.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
});
WorkflowPage.getters.canvasNodes().should('have.length', 4);
WorkflowPage.getters.nodeConnections().should('have.length', 2);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')).should('have.length', 1);
});
it('should not undo/redo when NDV or a modal is open', () => {
WorkflowPage.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, { keepNdvOpen: true });
// Try while NDV is open
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
ndv.getters.backToCanvas().click();
// Try while modal is open
cy.getByTestId('menu-item').contains('About n8n').click({ force: true });
cy.getByTestId('about-modal').should('be.visible');
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
cy.getByTestId('close-about-modal-button').click();
// Should work now
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 0);
});
it('should not undo/redo when NDV or a prompt is open', () => {
WorkflowPage.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, { keepNdvOpen: false });
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemImportFromURLItem().should('be.visible');
WorkflowPage.getters.workflowMenuItemImportFromURLItem().click();
// Try while prompt is open
WorkflowPage.getters.inputURLImportWorkflowFromURL().click();
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
// Close prompt and try again
WorkflowPage.getters.cancelActionImportWorkflowFromURL().click();
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 0);
});
});

View File

@ -1,90 +0,0 @@
import { WorkflowPage } from '../../pages';
import { getVisibleSelect } from '../../utils';
const wf = new WorkflowPage();
const TEST_TAGS = ['Tag 1', 'Tag 2', 'Tag 3', 'Tag 4', 'Tag 5'];
describe('Workflow tags', () => {
beforeEach(() => {
wf.actions.visit();
});
it('should create and attach tags inline', () => {
wf.getters.createTagButton().click();
wf.actions.addTags(TEST_TAGS.slice(0, 2));
wf.getters.tagPills().should('have.length', 2);
wf.getters.nthTagPill(1).click();
wf.actions.addTags(TEST_TAGS[2]);
wf.getters.tagPills().should('have.length', 3);
wf.getters.isWorkflowSaved();
});
it('should create tags via modal', () => {
wf.actions.openTagManagerModal();
const tags = TEST_TAGS.slice(3);
for (const tag of tags) {
cy.contains('Add new').click();
cy.getByTestId('tags-table').find('input').type(tag).type('{enter}');
cy.wait(300);
}
cy.contains('Done').click();
wf.getters.createTagButton().click();
wf.getters.tagsDropdown().click();
wf.getters.tagsInDropdown().should('have.length', 5);
wf.getters.tagPills().should('have.length', 0); // none attached
});
it('should delete all tags via modal', () => {
wf.actions.openTagManagerModal();
TEST_TAGS.forEach(() => {
cy.getByTestId('delete-tag-button').first().click({ force: true });
cy.contains('Delete tag').click();
cy.wait(300);
});
cy.contains('Done').click();
wf.getters.createTagButton().click();
wf.getters.tagsInDropdown().should('have.length', 0); // none stored
wf.getters.tagPills().should('have.length', 0); // none attached
});
it('should detach a tag inline by clicking on X on tag pill', () => {
wf.getters.createTagButton().click();
wf.actions.addTags(TEST_TAGS);
wf.getters.nthTagPill(1).click();
wf.getters.tagsDropdown().find('.el-tag__close').first().click();
cy.get('body').click(0, 0);
wf.getters.workflowTags().click();
wf.getters.tagPills().should('have.length', TEST_TAGS.length - 1);
});
it('should detach a tag inline by clicking on dropdown list item', () => {
wf.getters.createTagButton().click();
wf.actions.addTags(TEST_TAGS);
wf.getters.workflowTagsContainer().click();
wf.getters.tagsInDropdown().filter('.selected').first().click();
cy.get('body').click(0, 0);
wf.getters.workflowTags().click();
wf.getters.tagPills().should('have.length', TEST_TAGS.length - 1);
});
it('should not show non existing tag as a selectable option', () => {
const NON_EXISTING_TAG = 'My Test Tag';
wf.getters.createTagButton().click();
wf.actions.addTags(TEST_TAGS);
cy.get('body').click(0, 0);
wf.getters.workflowTags().click();
wf.getters.workflowTagsInput().type(NON_EXISTING_TAG);
getVisibleSelect()
.find('li')
.should('have.length', 2)
.filter(`:contains("${NON_EXISTING_TAG}")`)
.should('not.have.length');
});
});

View File

@ -1,244 +0,0 @@
import OnboardingWorkflow from '../../fixtures/Onboarding_workflow.json';
import WorkflowTemplate from '../../fixtures/Workflow_template_write_http_query.json';
import { MainSidebar } from '../../pages/sidebar/main-sidebar';
import { TemplatesPage } from '../../pages/templates';
import { WorkflowPage } from '../../pages/workflow';
import { WorkflowsPage } from '../../pages/workflows';
const templatesPage = new TemplatesPage();
const workflowPage = new WorkflowPage();
const workflowsPage = new WorkflowsPage();
const mainSidebar = new MainSidebar();
describe('Workflow templates', () => {
const mockTemplateHost = (host: string) => {
cy.overrideSettings({
templates: { enabled: true, host },
});
};
describe('For api.n8n.io', () => {
beforeEach(() => {
mockTemplateHost('https://api.n8n.io/api/');
});
it('Opens website when clicking templates sidebar link', () => {
cy.visit(workflowsPage.url);
mainSidebar.getters.templates().should('be.visible');
// Templates should be a link to the website
mainSidebar.getters
.templates()
.parent('a')
.should('have.attr', 'href')
.and('include', 'https://n8n.io/workflows');
// Link should contain instance address and n8n version
mainSidebar.getters
.templates()
.parent('a')
.then(($a) => {
const href = $a.attr('href');
const params = new URLSearchParams(href);
// Link should have all mandatory parameters expected on the website
expect(decodeURIComponent(`${params.get('utm_instance')}`)).to.include(
window.location.origin,
);
expect(params.get('utm_n8n_version')).to.match(/[0-9]+\.[0-9]+\.[0-9]+/);
expect(params.get('utm_awc')).to.match(/[0-9]+/);
});
mainSidebar.getters.templates().parent('a').should('have.attr', 'target', '_blank');
});
it('Redirects to website when visiting templates page directly', () => {
cy.intercept(
{
hostname: 'n8n.io',
pathname: '/workflows',
},
'Mock Template Page',
).as('templatesPage');
cy.visit(templatesPage.url);
cy.wait('@templatesPage');
});
});
describe('For a custom template host', () => {
const hostname = 'random.domain';
const categories = [
{ id: 1, name: 'Engineering' },
{ id: 2, name: 'Finance' },
{ id: 3, name: 'Sales' },
];
const collections = [
{
id: 1,
name: 'Test Collection',
workflows: [{ id: 1 }],
nodes: [],
},
];
beforeEach(() => {
cy.intercept({ hostname, pathname: '/api/health' }, { status: 'OK' });
cy.intercept({ hostname, pathname: '/api/templates/categories' }, { categories });
cy.intercept(
{ hostname, pathname: '/api/templates/collections', query: { category: '**' } },
(req) => {
req.reply({ collections: req.query['category[]'] === '3' ? [] : collections });
},
);
cy.intercept(
{ hostname, pathname: '/api/templates/search', query: { category: '**' } },
(req) => {
const fixture =
req.query.category === 'Sales'
? 'templates_search/sales_templates_search_response.json'
: 'templates_search/all_templates_search_response.json';
req.reply({ statusCode: 200, fixture });
},
);
cy.intercept(
{ hostname, pathname: '/api/workflows/templates/1' },
{
statusCode: 200,
body: {
id: 1,
name: OnboardingWorkflow.name,
workflow: OnboardingWorkflow,
},
},
).as('getTemplate');
cy.intercept(
{ hostname, pathname: '/api/templates/workflows/1' },
{
statusCode: 200,
body: WorkflowTemplate,
},
).as('getTemplatePreview');
mockTemplateHost(`https://${hostname}/api`);
});
it('can open onboarding flow', () => {
templatesPage.actions.openOnboardingFlow();
cy.url().should('match', /.*\/workflow\/.*?onboardingId=1$/);
workflowPage.actions.shouldHaveWorkflowName('Demo: ' + OnboardingWorkflow.name);
workflowPage.getters.canvasNodes().should('have.length', 4);
workflowPage.getters.stickies().should('have.length', 1);
});
it('can import template', () => {
templatesPage.actions.importTemplate();
cy.url().should('include', '/workflow/new?templateId=1');
workflowPage.getters.canvasNodes().should('have.length', 4);
workflowPage.getters.stickies().should('have.length', 1);
workflowPage.actions.shouldHaveWorkflowName(OnboardingWorkflow.name);
});
it('should save template id with the workflow', () => {
cy.intercept('POST', '/rest/workflows').as('saveWorkflow');
templatesPage.actions.importTemplate();
cy.visit(templatesPage.url);
cy.get('.el-skeleton.n8n-loading').should('not.exist');
templatesPage.getters.firstTemplateCard().should('exist');
templatesPage.getters.templatesLoadingContainer().should('not.exist');
templatesPage.getters.firstTemplateCard().click();
cy.url().should('include', '/templates/1');
cy.wait('@getTemplatePreview');
templatesPage.getters.useTemplateButton().click();
cy.url().should('include', '/workflow/new');
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.hitSelectAll();
workflowPage.actions.hitCopy();
cy.wait('@saveWorkflow').then((interception) => {
expect(interception.request.body.meta.templateId).to.equal('1');
});
});
it('can open template with images and hides workflow screenshots', () => {
cy.visit(`${templatesPage.url}/1`);
cy.wait('@getTemplatePreview');
templatesPage.getters.description().find('img').should('have.length', 1);
});
it('renders search elements correctly', () => {
cy.visit(templatesPage.url);
templatesPage.getters.searchInput().should('exist');
templatesPage.getters.allCategoriesFilter().should('exist');
templatesPage.getters.categoryFilters().should('have.length.greaterThan', 1);
templatesPage.getters.templateCards().should('have.length.greaterThan', 0);
});
it('can filter templates by category', () => {
cy.visit(templatesPage.url);
templatesPage.getters.templatesLoadingContainer().should('not.exist');
templatesPage.getters.categoryFilter('sales').should('exist');
let initialTemplateCount = 0;
let initialCollectionCount = 0;
templatesPage.getters.templateCountLabel().then(($el) => {
initialTemplateCount = parseInt($el.text().replace(/\D/g, ''), 10);
templatesPage.getters.collectionCountLabel().then(($el1) => {
initialCollectionCount = parseInt($el1.text().replace(/\D/g, ''), 10);
templatesPage.getters.categoryFilter('sales').click();
templatesPage.getters.templatesLoadingContainer().should('not.exist');
// Should have less templates and collections after selecting a category
templatesPage.getters.templateCountLabel().should(($el2) => {
expect(parseInt($el2.text().replace(/\D/g, ''), 10)).to.be.lessThan(
initialTemplateCount,
);
});
templatesPage.getters.collectionCountLabel().should(($el2) => {
expect(parseInt($el2.text().replace(/\D/g, ''), 10)).to.be.lessThan(
initialCollectionCount,
);
});
});
});
});
it('should preserve search query in URL', () => {
cy.visit(templatesPage.url);
templatesPage.getters.templatesLoadingContainer().should('not.exist');
templatesPage.getters.categoryFilter('sales').should('exist');
templatesPage.getters.categoryFilter('sales').click();
templatesPage.getters.searchInput().type('auto');
cy.url().should('include', '?categories=');
cy.url().should('include', '&search=');
cy.reload();
// Should preserve search query in URL
cy.url().should('include', '?categories=');
cy.url().should('include', '&search=');
// Sales category should still be selected
templatesPage.getters
.categoryFilter('sales')
.find('label')
.should('have.class', 'is-checked');
// Search input should still have the search query
templatesPage.getters.searchInput().should('have.value', 'auto');
// Sales checkbox should be pushed to the top
templatesPage.getters
.categoryFilters()
.eq(1)
.then(($el) => {
expect($el.text()).to.equal('Sales');
});
});
});
});

View File

@ -1,588 +0,0 @@
/* eslint-disable n8n-local-rules/no-skipped-tests */
import { type ICredentialType } from 'n8n-workflow';
import { clickCreateNewCredential, openCredentialSelect } from '../../composables/ndv';
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';
const wf = new WorkflowPage();
const ndv = new NDV();
const aiAssistant = new AIAssistant();
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
const nodeCreatorFeature = new NodeCreator();
describe('AI Assistant::enabled', () => {
beforeEach(() => {
aiAssistant.actions.enableAssistant();
wf.actions.visit();
});
after(() => {
aiAssistant.actions.disableAssistant();
});
it.skip('renders placeholder UI', () => {
aiAssistant.getters.askAssistantCanvasActionButton().should('be.visible');
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.placeholderMessage().should('be.visible');
aiAssistant.getters.chatInput().should('be.visible');
aiAssistant.getters.sendMessageButton().should('be.disabled');
aiAssistant.getters.closeChatButton().should('be.visible');
aiAssistant.getters.closeChatButton().click();
aiAssistant.getters.askAssistantChat().should('not.be.visible');
});
it('should resize assistant chat up', () => {
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.getters.askAssistantSidebarResizer().should('be.visible');
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.askAssistantChat().then((element) => {
const { width, left } = element[0].getBoundingClientRect();
cy.drag(aiAssistant.getters.askAssistantSidebarResizer(), [left - 10, 0], {
abs: true,
clickToFinish: true,
});
aiAssistant.getters.askAssistantChat().then((newElement) => {
const newWidth = newElement[0].getBoundingClientRect().width;
expect(newWidth).to.be.greaterThan(width);
});
});
});
it('should resize assistant chat down', () => {
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.getters.askAssistantSidebarResizer().should('be.visible');
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.askAssistantChat().then((element) => {
const { width, left } = element[0].getBoundingClientRect();
cy.drag(aiAssistant.getters.askAssistantSidebarResizer(), [left + 10, 0], {
abs: true,
clickToFinish: true,
});
aiAssistant.getters.askAssistantChat().then((newElement) => {
const newWidth = newElement[0].getBoundingClientRect().width;
expect(newWidth).to.be.lessThan(width);
});
});
});
it.skip('should start chat session from node error view', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesAll().should('have.length', 1);
aiAssistant.getters
.chatMessagesAll()
.eq(0)
.should('contain.text', 'Hey, this is an assistant message');
aiAssistant.getters.nodeErrorViewAssistantButton().should('be.disabled');
});
it.skip('should render chat input correctly', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
// Send button should be disabled when input is empty
aiAssistant.getters.sendMessageButton().should('be.disabled');
aiAssistant.getters.chatInput().type('Yo ');
aiAssistant.getters.sendMessageButton().should('not.be.disabled');
aiAssistant.getters.chatInput().then((element) => {
const { height } = element[0].getBoundingClientRect();
// Shift + Enter should add a new line
aiAssistant.getters.chatInput().type('Hello{shift+enter}there');
aiAssistant.getters.chatInput().then((newElement) => {
const newHeight = newElement[0].getBoundingClientRect().height;
// Chat input should grow as user adds new lines
expect(newHeight).to.be.greaterThan(height);
aiAssistant.getters.sendMessageButton().click();
cy.wait('@chatRequest');
// New lines should be rendered as <br> in the chat
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
aiAssistant.getters.chatMessagesUser().eq(0).find('br').should('have.length', 1);
// Chat input should be cleared now
aiAssistant.getters.chatInput().should('have.value', '');
});
});
});
it.skip('should render and handle quick replies', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/quick_reply_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.quickReplyButtons().should('have.length', 2);
aiAssistant.getters.quickReplyButtons().eq(0).click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', "Sure, let's do it");
});
it.skip('should warn before starting a new session', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Edit Fields');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
cy.wait('@chatRequest');
aiAssistant.getters.closeChatButton().click();
ndv.getters.backToCanvas().click();
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
// Since we already have an active session, a warning should be shown
aiAssistant.getters.newAssistantSessionModal().should('be.visible');
aiAssistant.getters
.newAssistantSessionModal()
.find('button')
.contains('Start new session')
.click();
cy.wait('@chatRequest');
// New session should start with initial assistant message
aiAssistant.getters.chatMessagesAll().should('have.length', 1);
});
it('should apply code diff to code node', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/code_diff_suggestion_response.json',
}).as('chatRequest');
cy.intercept('POST', '/rest/ai/chat/apply-suggestion', {
statusCode: 200,
fixture: 'aiAssistant/responses/apply_code_diff_response.json',
}).as('applySuggestion');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Code');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
cy.wait('@chatRequest');
// Should have two assistant messages
aiAssistant.getters.chatMessagesAll().should('have.length', 2);
aiAssistant.getters.codeDiffs().should('have.length', 1);
aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
aiAssistant.getters.applyCodeDiffButtons().first().click();
cy.wait('@applySuggestion');
aiAssistant.getters.applyCodeDiffButtons().should('have.length', 0);
aiAssistant.getters.undoReplaceCodeButtons().should('have.length', 1);
aiAssistant.getters.codeReplacedMessage().should('be.visible');
ndv.getters
.parameterInput('jsCode')
.get('.cm-content')
.should('contain.text', 'item.json.myNewField = 1');
// Clicking undo should revert the code back but not call the assistant
aiAssistant.getters.undoReplaceCodeButtons().first().click();
aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
aiAssistant.getters.codeReplacedMessage().should('not.exist');
cy.get('@applySuggestion.all').then((interceptions) => {
expect(interceptions).to.have.length(1);
});
ndv.getters
.parameterInput('jsCode')
.get('.cm-content')
.should('contain.text', 'item.json.myNewField = 1aaa');
// Replacing the code again should also not call the assistant
cy.get('@applySuggestion.all').then((interceptions) => {
expect(interceptions).to.have.length(1);
});
aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
aiAssistant.getters.applyCodeDiffButtons().first().click();
ndv.getters
.parameterInput('jsCode')
.get('.cm-content')
.should('contain.text', 'item.json.myNewField = 1');
});
it('Should ignore node execution success and error messages after the node run successfully once', () => {
const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible');
const getEditor = () => getParameter().find('.cm-content').should('exist');
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/code_diff_suggestion_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Code');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
cy.wait('@chatRequest');
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/node_execution_succeeded_response.json',
}).as('chatRequest2');
getEditor()
.type('{selectall}')
.paste(
'for (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();',
);
ndv.getters.nodeExecuteButton().click();
// Wait for a message from AI to be shown
aiAssistant.getters.chatMessagesAssistant().should('have.length', 3);
getEditor()
.type('{selectall}')
.paste(
'for (const item of $input.all()) {\n item.json.myNewField = 1aaaa!;\n}\n\nreturn $input.all();',
);
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.chatMessagesAssistant().should('have.length', 3);
aiAssistant.getters
.chatMessagesAssistant()
.eq(2)
.should(
'contain.text',
'Code node ran successfully, did my solution help resolve your issue?\nQuick reply 👇Yes, thanksNo, I am still stuck',
);
});
it.skip('should end chat session when `end_session` event is received', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/end_session_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesSystem().should('have.length', 1);
aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended');
});
it('should reset session after it ended and sidebar is closed', () => {
cy.intercept('POST', '/rest/ai/chat', (req) => {
req.reply((res) => {
if (['init-support-chat'].includes(req.body.payload.type)) {
res.send({
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
});
} else {
res.send({ statusCode: 200, fixture: 'aiAssistant/responses/end_session_response.json' });
}
});
}).as('chatRequest');
aiAssistant.actions.openChatFromCanvas();
aiAssistant.actions.sendMessage('Hello');
cy.wait('@chatRequest');
aiAssistant.actions.closeChat();
aiAssistant.actions.openChatFromCanvas();
// After closing and reopening the chat, all messages should be still there
aiAssistant.getters.chatMessagesAll().should('have.length', 2);
// End the session
aiAssistant.actions.sendMessage('Thanks, bye');
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesSystem().should('have.length', 1);
aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended');
aiAssistant.actions.closeChat();
aiAssistant.actions.openChatFromCanvas();
// Now, session should be reset
aiAssistant.getters.placeholderMessage().should('be.visible');
});
it('Should not reset assistant session when workflow is saved', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
wf.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
aiAssistant.actions.openChatFromCanvas();
aiAssistant.actions.sendMessage('Hello');
wf.actions.openNode(SCHEDULE_TRIGGER_NODE_NAME);
ndv.getters.nodeExecuteButton().click();
wf.getters.isWorkflowSaved();
aiAssistant.getters.placeholderMessage().should('not.exist');
});
it('should send message via enter even with global NodeCreator panel opened', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
wf.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
aiAssistant.actions.openChatFromCanvas();
nodeCreatorFeature.actions.openNodeCreator();
aiAssistant.getters.chatInput().type('Hello{Enter}');
aiAssistant.getters.placeholderMessage().should('not.exist');
});
});
describe('AI Assistant Credential Help', () => {
beforeEach(() => {
aiAssistant.actions.enableAssistant();
wf.actions.visit();
});
after(() => {
aiAssistant.actions.disableAssistant();
});
it('should start credential help from node credential', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
wf.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
wf.actions.addNodeToCanvas(GMAIL_NODE_NAME);
wf.actions.openNode('Add label to message');
openCredentialSelect();
clickCreateNewCredential();
aiAssistant.getters.credentialEditAssistantButton().find('button').should('be.visible');
aiAssistant.getters.credentialEditAssistantButton().find('button').click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
aiAssistant.getters
.chatMessagesUser()
.eq(0)
.should('contain.text', 'How do I set up the credentials for Gmail OAuth2 API?');
aiAssistant.getters
.chatMessagesAssistant()
.eq(0)
.should('contain.text', 'Hey, this is an assistant message');
aiAssistant.getters.credentialEditAssistantButton().find('button').should('be.disabled');
});
it('should start credential help from credential list', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.visit(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
aiAssistant.getters.credentialEditAssistantButton().find('button').should('be.visible');
aiAssistant.getters.credentialEditAssistantButton().find('button').click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
aiAssistant.getters
.chatMessagesUser()
.eq(0)
.should('contain.text', 'How do I set up the credentials for Notion API?');
aiAssistant.getters
.chatMessagesAssistant()
.eq(0)
.should('contain.text', 'Hey, this is an assistant message');
aiAssistant.getters.credentialEditAssistantButton().find('button').should('be.disabled');
});
it('should not show assistant button when click to connect', () => {
cy.intercept('/types/credentials.json', { middleware: true }, (req) => {
req.headers['cache-control'] = 'no-cache, no-store';
req.on('response', (res) => {
const credentials: ICredentialType[] = res.body || [];
const index = credentials.findIndex((c) => c.name === 'slackOAuth2Api');
credentials[index] = {
...credentials[index],
__overwrittenProperties: ['clientId', 'clientSecret'],
};
});
});
wf.actions.visit(true);
wf.actions.addNodeToCanvas('Manual');
wf.actions.addNodeToCanvas('Slack', true, true, 'Get a channel');
wf.getters.nodeCredentialsSelect().should('exist');
wf.getters.nodeCredentialsSelect().click();
wf.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
ndv.getters.copyInput().should('not.exist');
credentialsModal.getters.oauthConnectButton().should('have.length', 1);
credentialsModal.getters.credentialInputs().should('have.length', 2);
aiAssistant.getters.credentialEditAssistantButton().should('not.exist');
credentialsModal.getters.credentialAuthTypeRadioButtons().eq(1).click();
credentialsModal.getters.credentialInputs().should('have.length', 4);
aiAssistant.getters.credentialEditAssistantButton().should('exist');
});
it('should not show assistant button when click to connect with some fields', () => {
cy.intercept('/types/credentials.json', { middleware: true }, (req) => {
req.headers['cache-control'] = 'no-cache, no-store';
req.on('response', (res) => {
const credentials: ICredentialType[] = res.body || [];
const index = credentials.findIndex((c) => c.name === 'microsoftOutlookOAuth2Api');
credentials[index] = {
...credentials[index],
__overwrittenProperties: ['authUrl', 'accessTokenUrl', 'clientId', 'clientSecret'],
};
});
});
wf.actions.visit(true);
wf.actions.addNodeToCanvas('Manual');
wf.actions.addNodeToCanvas('Microsoft Outlook', true, true, 'Get a calendar');
wf.getters.nodeCredentialsSelect().should('exist');
wf.getters.nodeCredentialsSelect().click();
wf.getters.nodeCredentialsCreateOption().click();
ndv.getters.copyInput().should('not.exist');
credentialsModal.getters.oauthConnectButton().should('have.length', 1);
credentialsModal.getters.credentialInputs().should('have.length', 2);
aiAssistant.getters.credentialEditAssistantButton().should('not.exist');
});
});
describe('General help', () => {
beforeEach(() => {
aiAssistant.actions.enableAssistant();
wf.actions.visit();
});
it('assistant returns code snippet', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/code_snippet_response.json',
}).as('chatRequest');
aiAssistant.getters.askAssistantCanvasActionButton().should('be.visible');
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.placeholderMessage().should('be.visible');
aiAssistant.getters.chatInput().should('be.visible');
aiAssistant.getters.chatInput().type('Show me an expression');
aiAssistant.getters.sendMessageButton().click();
aiAssistant.getters.chatMessagesAll().should('have.length', 3);
aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', 'Show me an expression');
aiAssistant.getters
.chatMessagesAssistant()
.eq(0)
.should('contain.text', 'To use expressions in n8n, follow these steps:');
aiAssistant.getters
.chatMessagesAssistant()
.eq(0)
.should(
'include.html',
`<pre><code class="language-json">[
{
"headers": {
"host": "n8n.instance.address",
...
},
"params": {},
"query": {},
"body": {
"name": "Jim",
"age": 30,
"city": "New York"
}
}
]
</code></pre>`,
);
aiAssistant.getters.codeSnippet().should('have.text', '{{$json.body.city}}');
});
it('should send current context to support chat', () => {
cy.createFixtureWorkflow('aiAssistant/workflows/simple_http_request_workflow.json');
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
aiAssistant.getters.askAssistantCanvasActionButton().click();
aiAssistant.actions.sendMessage('What is wrong with this workflow?');
cy.wait('@chatRequest').then((interception) => {
const { body } = interception.request;
// Body should contain the current workflow context
expect(body.payload).to.have.property('context');
expect(body.payload.context).to.have.property('currentView');
expect(body.payload.context.currentView.name).to.equal('NodeViewExisting');
expect(body.payload.context).to.have.property('currentWorkflow');
});
});
it('should not send workflow context if nothing changed', () => {
cy.createFixtureWorkflow('aiAssistant/workflows/simple_http_request_workflow.json');
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
aiAssistant.getters.askAssistantCanvasActionButton().click();
wf.getters.zoomToFitButton().click();
aiAssistant.actions.sendMessage('What is wrong with this workflow?');
cy.wait('@chatRequest');
// Send another message without changing workflow or executing any node
aiAssistant.actions.sendMessage('And now?');
cy.wait('@chatRequest').then((interception) => {
const { body } = interception.request;
// Workflow context should be empty
expect(body.payload).to.have.property('context');
expect(body.payload.context).not.to.have.property('currentWorkflow');
});
// Update http request node url
wf.actions.openNode('HTTP Request');
ndv.actions.typeIntoParameterInput('url', 'https://example.com');
ndv.actions.close();
// Also execute the workflow
wf.actions.executeWorkflow();
// Send another message
aiAssistant.actions.sendMessage('What about now?');
cy.wait('@chatRequest').then((interception) => {
const { body } = interception.request;
// Both workflow and execution context should be sent
expect(body.payload).to.have.property('context');
expect(body.payload.context).to.have.property('currentWorkflow');
expect(body.payload.context.currentWorkflow).not.to.be.empty;
expect(body.payload.context).to.have.property('executionData');
expect(body.payload.context.executionData).not.to.be.empty;
});
});
});

View File

@ -1,428 +0,0 @@
import * as credentialsComposables from '../../composables/credentialsComposables';
import { saveCredential } from '../../composables/modals/credential-modal';
import * as projects from '../../composables/projects';
import {
INSTANCE_MEMBERS,
INSTANCE_OWNER,
INSTANCE_ADMIN,
NOTION_NODE_NAME,
} from '../../constants';
import {
CredentialsModal,
CredentialsPage,
NDV,
WorkflowPage,
WorkflowSharingModal,
WorkflowsPage,
} from '../../pages';
import { getVisibleDropdown, getVisiblePopper, getVisibleSelect } from '../../utils';
/**
* User U1 - Instance owner
* User U2 - User, owns C1, W1, W2
* User U3 - User, owns C2
*
* W1 - Workflow owned by User U2, shared with User U3
* W2 - Workflow owned by User U2
*
* C1 - Credential owned by User U2
* C2 - Credential owned by User U3, shared with User U1 and User U2
*/
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
const workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPage();
const workflowSharingModal = new WorkflowSharingModal();
const ndv = new NDV();
describe('Sharing', { disableAutoLogin: true }, () => {
before(() => cy.enableFeature('sharing'));
let workflowW2Url = '';
it('should create C1, W1, W2, share W1 with U3, as U2', () => {
cy.signinAsMember(0);
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('Credential C1');
credentialsModal.actions.save();
credentialsModal.actions.close();
workflowPage.actions.visit();
workflowPage.actions.setWorkflowName('Workflow W1');
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
workflowPage.actions.addNodeToCanvas('Notion', true, true);
ndv.getters.credentialInput().find('input').should('have.value', 'Credential C1');
ndv.actions.close();
workflowPage.actions.openShareModal();
workflowSharingModal.actions.addUser(INSTANCE_MEMBERS[1].email);
workflowSharingModal.actions.save();
workflowPage.actions.saveWorkflowOnButtonClick();
cy.visit(workflowsPage.url);
workflowsPage.getters.createWorkflowButton().click();
cy.createFixtureWorkflow('Test_workflow_1.json');
workflowPage.actions.setWorkflowName('Workflow W2');
cy.url().then((url) => {
workflowW2Url = url;
});
});
it('should create C2, share C2 with U1 and U2, as U3', () => {
cy.signinAsMember(1);
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialTypeOption('Airtable Personal Access Token API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Access Token').type('1234567890');
credentialsModal.actions.setName('Credential C2');
credentialsModal.actions.changeTab('Sharing');
credentialsModal.actions.addUser(INSTANCE_OWNER.email);
credentialsModal.actions.addUser(INSTANCE_MEMBERS[0].email);
credentialsModal.actions.save();
credentialsModal.actions.close();
});
it('should open W1, add node using C2 as U3', () => {
cy.signinAsMember(1);
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCards().should('have.length', 1);
workflowsPage.getters.workflowCardContent('Workflow W1').click();
workflowPage.actions.addNodeToCanvas('Airtable', true, true);
ndv.getters.credentialInput().find('input').should('have.value', 'Credential C2');
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.openNode('Append a block');
ndv.getters.credentialInput().should('have.value', 'Credential C1').should('be.disabled');
ndv.actions.close();
});
it('should open W1, add node using C2 as U2', () => {
cy.signinAsMember(0);
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardContent('Workflow W1').click();
workflowPage.actions.addNodeToCanvas('Airtable', true, true);
ndv.getters.credentialInput().find('input').should('have.value', 'Credential C2');
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.openNode('Append a block');
ndv.getters
.credentialInput()
.find('input')
.should('have.value', 'Credential C1')
.should('be.enabled');
ndv.actions.close();
});
it('should not have access to W2, as U3', () => {
cy.signinAsMember(1);
cy.visit(workflowW2Url);
cy.waitForLoad();
cy.location('pathname', { timeout: 10000 }).should('eq', '/entity-not-authorized/workflow');
});
it('should have access to W1, W2, as U1', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardContent('Workflow W1').click();
workflowPage.actions.openNode('Append a block');
ndv.getters
.credentialInput()
.find('input')
.should('have.value', 'Credential C1')
.should('be.enabled');
ndv.actions.close();
cy.waitForLoad();
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCardContent('Workflow W2').click('top');
workflowPage.actions.executeWorkflow();
});
it('should automatically test C2 when opened by U2 sharee', () => {
cy.signinAsMember(0);
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.credentialCard('Credential C2').click();
credentialsModal.getters.testSuccessTag().should('be.visible');
});
it('should work for admin role on credentials created by others (also can share it with themselves)', () => {
cy.signinAsMember(0);
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.createCredentialButton().click();
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click({ force: true });
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('Credential C3');
credentialsModal.actions.save();
credentialsModal.actions.close();
cy.signout();
cy.signinAsAdmin();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.credentialCard('Credential C3').click();
credentialsModal.getters.testSuccessTag().should('be.visible');
cy.get('input').should('not.have.length');
credentialsModal.actions.changeTab('Sharing');
cy.contains(
'Sharing a credential allows people to use it in their workflows. They cannot access credential details.',
).should('be.visible');
credentialsModal.getters.usersSelect().click();
getVisiblePopper()
.find('[data-test-id="project-sharing-info"]')
.filter(':visible')
.should('have.length', 3)
.contains(INSTANCE_ADMIN.email)
.should('have.length', 1);
getVisibleSelect().contains(INSTANCE_OWNER.email.toLowerCase()).click();
credentialsModal.actions.addUser(INSTANCE_MEMBERS[1].email);
credentialsModal.actions.addUser(INSTANCE_ADMIN.email);
credentialsModal.actions.saveSharing();
credentialsModal.actions.close();
});
it('credentials should work between team and personal projects', () => {
cy.resetDatabase();
cy.enableFeature('sharing');
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
cy.changeQuota('maxTeamProjects', -1);
cy.signinAsOwner();
cy.visit('/');
projects.createProject('Development');
projects.getHomeButton().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Test workflow');
projects.getHomeButton().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Notion API');
credentialsPage.getters.credentialCard('Notion API').click();
credentialsModal.actions.changeTab('Sharing');
credentialsModal.getters.usersSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 4)
.filter(':contains("Development")')
.should('have.length', 1)
.click();
saveCredential();
credentialsModal.actions.close();
projects.getProjectTabWorkflows().click();
workflowsPage.getters.workflowCardActions('Test workflow').click();
getVisibleDropdown().find('li').contains('Share').click();
workflowSharingModal.getters.usersSelect().filter(':visible').click();
getVisibleSelect().find('li').should('have.length', 3).first().click();
workflowSharingModal.getters.saveButton().click();
projects.getMenuItems().first().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Test workflow 2');
workflowPage.actions.openShareModal();
workflowSharingModal.getters.usersSelect().should('not.exist');
cy.get('body').type('{esc}');
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.createCredentialButton().click();
projects.createCredential('Notion API 2', false);
credentialsModal.actions.changeTab('Sharing');
credentialsModal.getters.usersSelect().click();
getVisibleSelect().find('li').should('have.length', 4).first().click();
saveCredential();
credentialsModal.actions.close();
credentialsPage.getters
.credentialCards()
.should('have.length', 2)
.filter(':contains("Personal")')
.should('have.length', 1);
});
});
describe('Credential Usage in Cross Shared Workflows', () => {
beforeEach(() => {
cy.resetDatabase();
cy.enableFeature('sharing');
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
cy.changeQuota('maxTeamProjects', -1);
cy.reload();
cy.signinAsOwner();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
});
it('should only show credentials from the same team project', () => {
// Create a notion credential in the home project
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// Create a notion credential in one project
projects.createProject('Development');
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// Create a notion credential in another project
projects.createProject('Test');
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// Create a workflow with a notion node in the same project
projects.getProjectTabWorkflows().click();
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the credential in this project should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 1);
});
it('should only show credentials in their personal project for members', () => {
// Create a notion credential as the owner
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// Create another notion credential as the owner, but share it with member
// 0
credentialsPage.getters.createCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API', false);
credentialsModal.actions.changeTab('Sharing');
credentialsModal.actions.addUser(INSTANCE_MEMBERS[0].email);
credentialsModal.actions.saveSharing();
// As the member, create a new notion credential and a workflow
cy.signinAsMember();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.createCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
cy.visit(workflowsPage.url);
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the own credential the shared one should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
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', () => {
const workflowName = 'Test workflow';
// Create a notion credential as the owner and a workflow that is shared
// with member 0
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
//cy.visit(workflowsPage.url);
projects.getProjectTabWorkflows().click();
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.setWorkflowName(workflowName);
workflowPage.actions.openShareModal();
workflowSharingModal.actions.addUser(INSTANCE_MEMBERS[0].email);
workflowSharingModal.actions.save();
// As the member, create a new notion credential
cy.signinAsMember();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCardContent(workflowName).click();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the own credential the shared one should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
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", () => {
const workflowName = 'Test workflow';
// As member 1, create a new notion credential. This should not show up.
cy.signinAsMember(1);
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// As admin, create a new notion credential. This should show up.
cy.signinAsAdmin();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.createCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// As member 0, create a new notion credential and a workflow and share it
// with the global owner and the admin.
cy.signinAsMember();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
cy.visit(workflowsPage.url);
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.setWorkflowName(workflowName);
workflowPage.actions.openShareModal();
workflowSharingModal.actions.addUser(INSTANCE_OWNER.email);
workflowSharingModal.actions.addUser(INSTANCE_ADMIN.email);
workflowSharingModal.actions.save();
// As the global owner, create a new notion credential and open the shared
// workflow
cy.signinAsOwner();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.createCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
cy.visit(workflowsPage.url);
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.
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 3);
});
it('should show all personal credentials if the global owner owns the workflow', () => {
// As member 0, create a new notion credential.
cy.signinAsMember();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// As the global owner, create a workflow and add a notion node
cy.signinAsOwner();
cy.visit(workflowsPage.url);
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Show all personal credentials
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.have.length', 1);
});
});

View File

@ -1,225 +0,0 @@
import * as setupCredsModal from '../../composables/modals/workflow-credential-setup-modal';
import * as formStep from '../../composables/setup-template-form-step';
import { getSetupWorkflowCredentialsButton } from '../../composables/setup-workflow-credentials-button';
import TestTemplate1 from '../../fixtures/Test_Template_1.json';
import TestTemplate2 from '../../fixtures/Test_Template_2.json';
import { clearNotifications } from '../../pages/notifications';
import {
clickUseWorkflowButtonByTitle,
visitTemplateCollectionPage,
testData,
} from '../../pages/template-collection';
import * as templateCredentialsSetupPage from '../../pages/template-credential-setup';
import { WorkflowPage } from '../../pages/workflow';
const workflowPage = new WorkflowPage();
const testTemplate = {
id: 1205,
data: TestTemplate1,
};
const templateWithoutCredentials = {
id: 1344,
data: TestTemplate2,
};
// NodeView uses beforeunload listener that will show a browser
// native popup, which will block cypress from continuing / exiting.
// This prevent the registration of the listener.
Cypress.on('window:before:load', (win) => {
const origAddEventListener = win.addEventListener;
win.addEventListener = (eventName: string, listener: any, opts: any) => {
if (eventName === 'beforeunload') {
return;
}
return origAddEventListener.call(win, eventName, listener, opts);
};
});
describe('Template credentials setup', () => {
beforeEach(() => {
cy.intercept(
'GET',
`https://api.n8n.io/api/templates/workflows/${testTemplate.id}`,
testTemplate.data,
).as('getTemplatePreview');
cy.intercept(
'GET',
`https://api.n8n.io/api/workflows/templates/${testTemplate.id}`,
testTemplate.data.workflow,
).as('getTemplate');
cy.overrideSettings({
templates: { enabled: true, host: 'https://api.n8n.io/api/' },
});
});
it('can be opened from template collection page', () => {
visitTemplateCollectionPage(testData.ecommerceStarterPack);
clickUseWorkflowButtonByTitle('Promote new Shopify products');
templateCredentialsSetupPage.getters
.title("Set up 'Promote new Shopify products' template")
.should('be.visible');
});
it('has all the elements on page', () => {
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
templateCredentialsSetupPage.getters
.title("Set up 'Promote new Shopify products' template")
.should('be.visible');
templateCredentialsSetupPage.getters
.infoCallout()
.should(
'contain.text',
'You need 1x Shopify, 1x X (Formerly Twitter) and 1x Telegram account to setup this template',
);
const expectedAppNames = ['1. Shopify', '2. X (Formerly Twitter)', '3. Telegram'];
const expectedAppDescriptions = [
'The credential you select will be used in the product created node of the workflow template.',
'The credential you select will be used in the Twitter node of the workflow template.',
'The credential you select will be used in the Telegram node of the workflow template.',
];
formStep.getFormStep().each(($el, index) => {
formStep.getStepHeading($el).should('have.text', expectedAppNames[index]);
formStep.getStepDescription($el).should('have.text', expectedAppDescriptions[index]);
});
});
it('can skip template creation', () => {
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
templateCredentialsSetupPage.getters.skipLink().click();
workflowPage.getters.canvasNodes().should('have.length', 3);
});
it('can create credentials and workflow from the template', () => {
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
// Continue button should be disabled if no credentials are created
templateCredentialsSetupPage.getters.continueButton().should('be.disabled');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
// Continue button should be enabled if at least one has been created
templateCredentialsSetupPage.getters.continueButton().should('be.enabled');
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
workflowPage.getters.canvasNodes().should('have.length', 3);
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Focus the canvas so the copy to clipboard works
workflowPage.getters.canvasNodes().eq(0).realClick();
workflowPage.actions.hitSelectAll();
workflowPage.actions.hitCopy();
// Check workflow JSON by copying it to clipboard
cy.readClipboard().then((workflowJSON) => {
const workflow = JSON.parse(workflowJSON);
expect(workflow.meta).to.haveOwnProperty('templateId', testTemplate.id.toString());
expect(workflow.meta).not.to.haveOwnProperty('templateCredsSetupCompleted');
workflow.nodes.forEach((node: any) => {
expect(Object.keys(node.credentials ?? {})).to.have.lengthOf(1);
});
});
});
it('should work with a template that has no credentials (ADO-1603)', () => {
const { id, data } = templateWithoutCredentials;
cy.intercept('GET', `https://api.n8n.io/api/templates/workflows/${id}`, data);
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(id);
const expectedAppNames = ['1. Email (IMAP)', '2. Nextcloud'];
const expectedAppDescriptions = [
'The credential you select will be used in the IMAP Email node of the workflow template.',
'The credential you select will be used in the Nextcloud node of the workflow template.',
];
formStep.getFormStep().each(($el, index) => {
formStep.getStepHeading($el).should('have.text', expectedAppNames[index]);
formStep.getStepDescription($el).should('have.text', expectedAppDescriptions[index]);
});
templateCredentialsSetupPage.getters.continueButton().should('be.disabled');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
workflowPage.getters.canvasNodes().should('have.length', 3);
});
describe('Credential setup from workflow editor', { disableAutoLogin: true }, () => {
beforeEach(() => {
cy.resetDatabase();
cy.signinAsOwner();
});
it('should allow credential setup from workflow editor if user skips it during template setup', () => {
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
templateCredentialsSetupPage.getters.skipLink().click();
getSetupWorkflowCredentialsButton().should('be.visible');
});
it('should allow credential setup from workflow editor if user fills in credentials partially during template setup', () => {
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
getSetupWorkflowCredentialsButton().should('be.visible');
});
it('should fill credentials from workflow editor', () => {
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
templateCredentialsSetupPage.getters.skipLink().click();
getSetupWorkflowCredentialsButton().click();
setupCredsModal.getWorkflowCredentialsModal().should('be.visible');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
clearNotifications();
setupCredsModal.closeModalFromContinueButton();
setupCredsModal.getWorkflowCredentialsModal().should('not.exist');
// Focus the canvas so the copy to clipboard works
workflowPage.getters.canvasNodes().eq(0).realClick();
workflowPage.actions.hitSelectAll();
workflowPage.actions.hitCopy();
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Check workflow JSON by copying it to clipboard
cy.readClipboard().then((workflowJSON) => {
const workflow = JSON.parse(workflowJSON);
workflow.nodes.forEach((node: any) => {
expect(Object.keys(node.credentials ?? {})).to.have.lengthOf(1);
});
});
getSetupWorkflowCredentialsButton().should('not.exist');
});
});
});

Some files were not shown because too many files have changed in this diff Show More