mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
Merge branch 'master' into fix/remove-duplicates-rss-bug
This commit is contained in:
commit
48837a9325
|
|
@ -18,5 +18,4 @@ packages/**/*.test.*
|
|||
docker/compose
|
||||
docker/**/Dockerfile
|
||||
.vscode
|
||||
packages/testing
|
||||
cypress
|
||||
packages/testing
|
||||
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/01-bug.yml
vendored
2
.github/ISSUE_TEMPLATE/01-bug.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/actionlint.yaml
vendored
2
.github/actionlint.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/scripts/bump-versions.mjs
vendored
6
.github/scripts/bump-versions.mjs
vendored
|
|
@ -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
177
.github/scripts/docker/docker-config.mjs
vendored
Normal 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
113
.github/scripts/docker/docker-tags.mjs
vendored
Normal 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;
|
||||
2
.github/scripts/package.json
vendored
2
.github/scripts/package.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
18
.github/workflows/benchmark-nightly.yml
vendored
18
.github/workflows/benchmark-nightly.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
100
.github/workflows/build-unit-test-pr-comment.yml
vendored
Normal file
100
.github/workflows/build-unit-test-pr-comment.yml
vendored
Normal 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
81
.github/workflows/build-windows.yml
vendored
Normal 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 }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.github/workflows/check-pr-title.yml
vendored
3
.github/workflows/check-pr-title.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
6
.github/workflows/chromatic.yml
vendored
6
.github/workflows/chromatic.yml
vendored
|
|
@ -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
73
.github/workflows/ci-evals.yml
vendored
Normal 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
|
||||
132
.github/workflows/ci-manual-build-unit-tests.yml
vendored
Normal file
132
.github/workflows/ci-manual-build-unit-tests.yml
vendored
Normal 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
|
||||
3
.github/workflows/ci-master.yml
vendored
3
.github/workflows/ci-master.yml
vendored
|
|
@ -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 }})
|
||||
|
|
|
|||
11
.github/workflows/ci-postgres-mysql.yml
vendored
11
.github/workflows/ci-postgres-mysql.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
57
.github/workflows/ci-pull-requests.yml
vendored
57
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
56
.github/workflows/ci-python-workflow-builder-evals.yml
vendored
Normal file
56
.github/workflows/ci-python-workflow-builder-evals.yml
vendored
Normal 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
|
||||
2
.github/workflows/ci-python.yml
vendored
2
.github/workflows/ci-python.yml
vendored
|
|
@ -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
84
.github/workflows/data-tooling.yml
vendored
Normal 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
|
||||
21
.github/workflows/docker-base-image.yml
vendored
21
.github/workflows/docker-base-image.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
520
.github/workflows/docker-build-push.yml
vendored
520
.github/workflows/docker-build-push.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
125
.github/workflows/e2e-reusable.yml
vendored
125
.github/workflows/e2e-reusable.yml
vendored
|
|
@ -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
|
||||
44
.github/workflows/e2e-tests-pr.yml
vendored
44
.github/workflows/e2e-tests-pr.yml
vendored
|
|
@ -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
|
||||
78
.github/workflows/e2e-tests.yml
vendored
78
.github/workflows/e2e-tests.yml
vendored
|
|
@ -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
|
||||
4
.github/workflows/linting-reusable.yml
vendored
4
.github/workflows/linting-reusable.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
55
.github/workflows/playwright-test-ci.yml
vendored
Normal file
55
.github/workflows/playwright-test-ci.yml
vendored
Normal 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'
|
||||
68
.github/workflows/playwright-test-coverage.yml
vendored
Normal file
68
.github/workflows/playwright-test-coverage.yml
vendored
Normal 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
|
||||
|
|
@ -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
|
||||
51
.github/workflows/playwright-test-reusable.yml
vendored
51
.github/workflows/playwright-test-reusable.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
20
.github/workflows/release-create-pr.yml
vendored
20
.github/workflows/release-create-pr.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
28
.github/workflows/release-publish.yml
vendored
28
.github/workflows/release-publish.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
19
.github/workflows/release-push-to-channel.yml
vendored
19
.github/workflows/release-push-to-channel.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
6
.github/workflows/storybook.yml
vendored
6
.github/workflows/storybook.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
2
.github/workflows/sync-public-api-docs.yml
vendored
2
.github/workflows/sync-public-api-docs.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
.github/workflows/test-workflows-pr-approved.yml
vendored
25
.github/workflows/test-workflows-pr-approved.yml
vendored
|
|
@ -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
|
||||
127
.github/workflows/units-tests-reusable.yml
vendored
127
.github/workflows/units-tests-reusable.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
60
.github/workflows/update-node-popularity.yml
vendored
Normal file
60
.github/workflows/update-node-popularity.yml
vendored
Normal 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
1
.gitignore
vendored
|
|
@ -42,4 +42,5 @@ packages/testing/**/.cursor/rules/
|
|||
__pycache__
|
||||
packages/cli/THIRD_PARTY_LICENSES.md
|
||||
.coverage
|
||||
.nyc_output
|
||||
packages/cli/src/commands/export/outputs
|
||||
|
|
@ -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
6
.vscode/launch.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
3
.vscode/settings.default.json
vendored
3
.vscode/settings.default.json
vendored
|
|
@ -33,5 +33,6 @@
|
|||
{
|
||||
"mode": "auto"
|
||||
}
|
||||
]
|
||||
],
|
||||
"vitest.workspaceConfig": "./vitest.workspace.ts"
|
||||
}
|
||||
|
|
|
|||
157
AGENTS.md
Normal file
157
AGENTS.md
Normal 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.
|
||||
709
CHANGELOG.md
709
CHANGELOG.md
|
|
@ -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
160
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
12
codecov.yml
12
codecov.yml
|
|
@ -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
115
cubic.yaml
Normal 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
3
cypress/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
videos/
|
||||
screenshots/
|
||||
downloads/
|
||||
|
|
@ -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
|
||||
```
|
||||
4
cypress/augmentation.d.ts
vendored
4
cypress/augmentation.d.ts
vendored
|
|
@ -1,4 +0,0 @@
|
|||
declare module 'cypress-otp' {
|
||||
// eslint-disable-next-line import-x/no-default-export
|
||||
export default function generateOTPToken(secret: string): string;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"$schema": "../node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"extends": ["../biome.jsonc"],
|
||||
"formatter": {
|
||||
"ignore": ["fixtures/**"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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")`);
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
/**
|
||||
* Getters
|
||||
*/
|
||||
|
||||
export const getSetupWorkflowCredentialsButton = () => cy.get('button:contains("Set up template")');
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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}');
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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');
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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\/.+/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Reference in New Issue
Block a user