mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-21 02:56:40 -08:00
Merge branch 'master' into fix-grammar-on-english-locale
This commit is contained in:
commit
5fe771b2d9
11
.github/workflows/ci-master.yml
vendored
11
.github/workflows/ci-master.yml
vendored
|
@ -7,7 +7,9 @@ on:
|
|||
|
||||
jobs:
|
||||
install-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2204
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
|
||||
timeout-minutes: 10
|
||||
|
||||
|
@ -15,7 +17,7 @@ jobs:
|
|||
- uses: actions/checkout@v4.1.1
|
||||
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@v4.0.2
|
||||
- uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: pnpm
|
||||
|
@ -24,13 +26,13 @@ jobs:
|
|||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup build cache
|
||||
uses: rharkor/caching-for-turbo@v1.5
|
||||
uses: useblacksmith/caching-for-turbo@v1
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache/save@v4.0.0
|
||||
uses: useblacksmith/cache/save@v5
|
||||
with:
|
||||
path: ./packages/**/dist
|
||||
key: ${{ github.sha }}-base:build
|
||||
|
@ -48,6 +50,7 @@ jobs:
|
|||
cacheKey: ${{ github.sha }}-base:build
|
||||
collectCoverage: ${{ matrix.node-version == '20.x' }}
|
||||
ignoreTurboCache: ${{ matrix.node-version == '20.x' }}
|
||||
skipFrontendTests: ${{ matrix.node-version != '20.x' }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
|
|
10
.github/workflows/ci-pull-requests.yml
vendored
10
.github/workflows/ci-pull-requests.yml
vendored
|
@ -9,14 +9,16 @@ on:
|
|||
jobs:
|
||||
install-and-build:
|
||||
name: Install & Build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2204
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.pull_request.number }}/merge
|
||||
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@v4.0.2
|
||||
- uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: pnpm
|
||||
|
@ -25,7 +27,7 @@ jobs:
|
|||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup build cache
|
||||
uses: rharkor/caching-for-turbo@v1.5
|
||||
uses: useblacksmith/caching-for-turbo@v1
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
@ -37,7 +39,7 @@ jobs:
|
|||
run: pnpm typecheck
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache/save@v4.0.0
|
||||
uses: useblacksmith/cache/save@v5
|
||||
with:
|
||||
path: ./packages/**/dist
|
||||
key: ${{ github.sha }}-base:build
|
||||
|
|
7
.github/workflows/e2e-reusable.yml
vendored
7
.github/workflows/e2e-reusable.yml
vendored
|
@ -41,11 +41,6 @@ on:
|
|||
description: 'PR number to run tests for.'
|
||||
required: false
|
||||
type: number
|
||||
node_view_version:
|
||||
description: 'Node View version to run tests with.'
|
||||
required: false
|
||||
default: '1'
|
||||
type: string
|
||||
secrets:
|
||||
CYPRESS_RECORD_KEY:
|
||||
description: 'Cypress record key.'
|
||||
|
@ -165,7 +160,7 @@ jobs:
|
|||
spec: '${{ inputs.spec }}'
|
||||
env:
|
||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||
CYPRESS_NODE_VIEW_VERSION: ${{ inputs.node_view_version }}
|
||||
CYPRESS_NODE_VIEW_VERSION: 2
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
E2E_TESTS: true
|
||||
|
|
6
.github/workflows/e2e-tests.yml
vendored
6
.github/workflows/e2e-tests.yml
vendored
|
@ -27,11 +27,6 @@ on:
|
|||
description: 'URL to call after workflow is done.'
|
||||
required: false
|
||||
default: ''
|
||||
node_view_version:
|
||||
description: 'Node View version to run tests with.'
|
||||
required: false
|
||||
default: '1'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
calls-start-url:
|
||||
|
@ -51,7 +46,6 @@ jobs:
|
|||
branch: ${{ github.event.inputs.branch || 'master' }}
|
||||
user: ${{ github.event.inputs.user || 'PR User' }}
|
||||
spec: ${{ github.event.inputs.spec || 'e2e/*' }}
|
||||
node_view_version: ${{ github.event.inputs.node_view_version || '1' }}
|
||||
secrets:
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
|
||||
|
|
11
.github/workflows/linting-reusable.yml
vendored
11
.github/workflows/linting-reusable.yml
vendored
|
@ -17,14 +17,16 @@ on:
|
|||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2204
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@v4.0.2
|
||||
- uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: pnpm
|
||||
|
@ -33,7 +35,7 @@ jobs:
|
|||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup build cache
|
||||
uses: rharkor/caching-for-turbo@v1.5
|
||||
uses: useblacksmith/caching-for-turbo@v1
|
||||
|
||||
- name: Build
|
||||
if: ${{ inputs.cacheKey == '' }}
|
||||
|
@ -41,10 +43,11 @@ jobs:
|
|||
|
||||
- name: Restore cached build artifacts
|
||||
if: ${{ inputs.cacheKey != '' }}
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
uses: useblacksmith/cache/restore@v5
|
||||
with:
|
||||
path: ./packages/**/dist
|
||||
key: ${{ inputs.cacheKey }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Lint Backend
|
||||
run: pnpm lint:backend
|
||||
|
|
6
.github/workflows/units-tests-dispatch.yml
vendored
6
.github/workflows/units-tests-dispatch.yml
vendored
|
@ -12,6 +12,11 @@ on:
|
|||
description: 'PR number to run tests for.'
|
||||
required: false
|
||||
type: number
|
||||
skipFrontendTests:
|
||||
description: 'Skip Frontend tests'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
|
@ -37,3 +42,4 @@ jobs:
|
|||
uses: ./.github/workflows/units-tests-reusable.yml
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.branch }}
|
||||
skipFrontendTests: ${{ inputs.skipFrontendTests }}
|
||||
|
|
14
.github/workflows/units-tests-reusable.yml
vendored
14
.github/workflows/units-tests-reusable.yml
vendored
|
@ -26,6 +26,10 @@ on:
|
|||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skipFrontendTests:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
description: 'Codecov upload token.'
|
||||
|
@ -34,7 +38,7 @@ on:
|
|||
jobs:
|
||||
unit-test:
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2204
|
||||
env:
|
||||
TURBO_FORCE: ${{ inputs.ignoreTurboCache }}
|
||||
COVERAGE_ENABLED: ${{ inputs.collectCoverage }}
|
||||
|
@ -45,7 +49,7 @@ jobs:
|
|||
|
||||
- run: corepack enable
|
||||
- name: Use Node.js ${{ inputs.nodeVersion }}
|
||||
uses: actions/setup-node@v4.0.2
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ inputs.nodeVersion }}
|
||||
cache: pnpm
|
||||
|
@ -54,7 +58,7 @@ jobs:
|
|||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup build cache
|
||||
uses: rharkor/caching-for-turbo@v1.5
|
||||
uses: useblacksmith/caching-for-turbo@v1
|
||||
|
||||
- name: Build
|
||||
if: ${{ inputs.cacheKey == '' }}
|
||||
|
@ -62,10 +66,11 @@ jobs:
|
|||
|
||||
- name: Restore cached build artifacts
|
||||
if: ${{ inputs.cacheKey != '' }}
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
uses: useblacksmith/cache/restore@v5
|
||||
with:
|
||||
path: ./packages/**/dist
|
||||
key: ${{ inputs.cacheKey }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Test Backend
|
||||
run: pnpm test:backend
|
||||
|
@ -74,6 +79,7 @@ jobs:
|
|||
run: pnpm test:nodes
|
||||
|
||||
- name: Test Frontend
|
||||
if: ${{ !inputs.skipFrontendTests }}
|
||||
run: pnpm test:frontend
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
|
|
100
CHANGELOG.md
100
CHANGELOG.md
|
@ -1,3 +1,103 @@
|
|||
# [1.77.0](https://github.com/n8n-io/n8n/compare/n8n@1.76.0...n8n@1.77.0) (2025-01-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Account for pre-execution failure in scaling mode ([#12815](https://github.com/n8n-io/n8n/issues/12815)) ([b4d27c4](https://github.com/n8n-io/n8n/commit/b4d27c49e32bfacbd2690bf1c07194562f6a4a61))
|
||||
* **core:** Display the last activated plan name when multiple are activated ([#12835](https://github.com/n8n-io/n8n/issues/12835)) ([03365f0](https://github.com/n8n-io/n8n/commit/03365f096d3d5c8e3a6537f37cda412959705346))
|
||||
* **core:** Fix possible corruption of OAuth2 credential ([#12880](https://github.com/n8n-io/n8n/issues/12880)) ([ac84ea1](https://github.com/n8n-io/n8n/commit/ac84ea14452cbcec95f14073e8e70427169e6a7f))
|
||||
* **core:** Fix usage of external libs in task runner ([#12788](https://github.com/n8n-io/n8n/issues/12788)) ([3d9d5bf](https://github.com/n8n-io/n8n/commit/3d9d5bf9d58f3c49830d42a140d6c8c6b59952dc))
|
||||
* **core:** Handle max stalled count error better ([#12824](https://github.com/n8n-io/n8n/issues/12824)) ([eabf160](https://github.com/n8n-io/n8n/commit/eabf1609577cd94a6bad5020c34378d840a13bc0))
|
||||
* **core:** Improve error handling in credential decryption and parsing ([#12868](https://github.com/n8n-io/n8n/issues/12868)) ([0c86bf2](https://github.com/n8n-io/n8n/commit/0c86bf2b3761bb93fd3cedba7a483ae5d97bd332))
|
||||
* **core:** Renew license on startup for instances with detached floating entitlements ([#12884](https://github.com/n8n-io/n8n/issues/12884)) ([f32eef8](https://github.com/n8n-io/n8n/commit/f32eef85bd066ee9b54d110355c6b80124d67437))
|
||||
* **core:** Update execution entity and execution data in transaction ([#12756](https://github.com/n8n-io/n8n/issues/12756)) ([1f43181](https://github.com/n8n-io/n8n/commit/1f4318136011bffaad04527790a9eba79effce35))
|
||||
* **core:** Validate credential data before encryption ([#12885](https://github.com/n8n-io/n8n/issues/12885)) ([3d27a14](https://github.com/n8n-io/n8n/commit/3d27a1498702206b738cf978d037191306cec42b))
|
||||
* **editor:** Add notice when user hits the limit for execution metadata item length ([#12676](https://github.com/n8n-io/n8n/issues/12676)) ([02df25c](https://github.com/n8n-io/n8n/commit/02df25c450a0a384a32d0815d8a2faec7562a8ae))
|
||||
* **editor:** Don't send run data for full manual executions ([#12687](https://github.com/n8n-io/n8n/issues/12687)) ([9139dc3](https://github.com/n8n-io/n8n/commit/9139dc3c2916186648fb5bf63d14fcb90773eb1c))
|
||||
* **editor:** Fix sub-execution links in empty output tables ([#12781](https://github.com/n8n-io/n8n/issues/12781)) ([114ed88](https://github.com/n8n-io/n8n/commit/114ed88368d137443b9c6605d4fe11b02053549d))
|
||||
* **editor:** Fix workflow move project select filtering ([#12764](https://github.com/n8n-io/n8n/issues/12764)) ([358d284](https://github.com/n8n-io/n8n/commit/358d2843e5e468071d6764419169811e93138c35))
|
||||
* **editor:** Focus executions iframe when n8n is ready to delegate keyboard events ([#12741](https://github.com/n8n-io/n8n/issues/12741)) ([d506218](https://github.com/n8n-io/n8n/commit/d5062189dbca02dfdf485fc220cc2a7b05e3e6cc))
|
||||
* **editor:** Handle large payloads in the AI Assistant requests better ([#12747](https://github.com/n8n-io/n8n/issues/12747)) ([eb4dea1](https://github.com/n8n-io/n8n/commit/eb4dea1ca891bb7ac07c8bbbae8803de080c4623))
|
||||
* **editor:** Hide Set up Template button for empty workflows ([#12808](https://github.com/n8n-io/n8n/issues/12808)) ([36e615b](https://github.com/n8n-io/n8n/commit/36e615b28f395623457bbb9bf4ab6fd69102b6ea))
|
||||
* **editor:** Load appropriate credentials in canvas V2 for new workflow ([#12722](https://github.com/n8n-io/n8n/issues/12722)) ([2020dc5](https://github.com/n8n-io/n8n/commit/2020dc502feae6cae827dfbcc40ffed89bcc334a))
|
||||
* **editor:** Properly set active project in new canvas ([#12810](https://github.com/n8n-io/n8n/issues/12810)) ([648c6f9](https://github.com/n8n-io/n8n/commit/648c6f9315b16b885e04716e7e0035a73b358fb0))
|
||||
* **editor:** Render inline SVGs correctly on the external secrets settings page ([#12802](https://github.com/n8n-io/n8n/issues/12802)) ([5820ade](https://github.com/n8n-io/n8n/commit/5820ade1e4b9d638c9b6369aef369d6dc9320da6))
|
||||
* **editor:** Show input selector when node has error ([#12813](https://github.com/n8n-io/n8n/issues/12813)) ([5b760e7](https://github.com/n8n-io/n8n/commit/5b760e7f7fc612b10307b4871e24b549f5d9d420))
|
||||
* **editor:** Show mappings by default in sub-node NDVs when the root node isn't executed ([#12642](https://github.com/n8n-io/n8n/issues/12642)) ([fb662dd](https://github.com/n8n-io/n8n/commit/fb662dd95cae3bc51d05d05e32e772d05adafa1e))
|
||||
* **Postgres PGVector Store Node:** Release postgres connections back to the pool ([#12723](https://github.com/n8n-io/n8n/issues/12723)) ([663dfb4](https://github.com/n8n-io/n8n/commit/663dfb48defd944f88f0ecc4f3347ea4f8a7c831))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add DeepSeek Chat Model node ([#12873](https://github.com/n8n-io/n8n/issues/12873)) ([9918afa](https://github.com/n8n-io/n8n/commit/9918afa51b16116abb73692a66df84e48128f406))
|
||||
* Add OpenRouter node ([#12882](https://github.com/n8n-io/n8n/issues/12882)) ([dc85b02](https://github.com/n8n-io/n8n/commit/dc85b022d111d1e8b038ca1a9f6a1041f19cf2b0))
|
||||
* Add timeout options to sendAndWait operations ([#12753](https://github.com/n8n-io/n8n/issues/12753)) ([3e9f24d](https://github.com/n8n-io/n8n/commit/3e9f24ddf462349145d89fe183313c95512c699b))
|
||||
* **API:** Add route for schema static files ([#12770](https://github.com/n8n-io/n8n/issues/12770)) ([d981b56](https://github.com/n8n-io/n8n/commit/d981b5659a26f92b11e5d0cd5570504fd683626c))
|
||||
* **core:** Explicitly report external hook failures ([#12830](https://github.com/n8n-io/n8n/issues/12830)) ([a24e442](https://github.com/n8n-io/n8n/commit/a24e4420bb9023f808acd756d125dffaea325968))
|
||||
* **core:** Rename two task runner env vars ([#12763](https://github.com/n8n-io/n8n/issues/12763)) ([60187ca](https://github.com/n8n-io/n8n/commit/60187cab9bc9d21aa6ba710d772c068324e429f1))
|
||||
* **editor:** Add evaluation workflow and enhance workflow selector with pinned data support ([#12773](https://github.com/n8n-io/n8n/issues/12773)) ([be967eb](https://github.com/n8n-io/n8n/commit/be967ebec07fab223513f93f50bcc389b9a4c548))
|
||||
* **editor:** Always keep at least one executing node indicator in the workflow ([#12829](https://github.com/n8n-io/n8n/issues/12829)) ([c25c613](https://github.com/n8n-io/n8n/commit/c25c613a04a6773fa4014d9a0d290e443bcabbe0))
|
||||
* **Google Chat Node:** Updates ([#12827](https://github.com/n8n-io/n8n/issues/12827)) ([e146ad0](https://github.com/n8n-io/n8n/commit/e146ad021a0be22cf51bafa3c015d03550e03d97))
|
||||
* **Microsoft Outlook Node:** New operation sendAndWait ([#12795](https://github.com/n8n-io/n8n/issues/12795)) ([f4bf55f](https://github.com/n8n-io/n8n/commit/f4bf55f0d8278ff954344cf6397c10d8261b39a4))
|
||||
* **n8n Form Node:** Add read-only/custom HTML form elements ([#12760](https://github.com/n8n-io/n8n/issues/12760)) ([ba8aa39](https://github.com/n8n-io/n8n/commit/ba8aa3921613c590caaac627fbb9837ccaf87783))
|
||||
* **Send Email Node:** New operation sendAndWait ([#12775](https://github.com/n8n-io/n8n/issues/12775)) ([a197fbb](https://github.com/n8n-io/n8n/commit/a197fbb21b5642843d8bc3e657049aca99e0729d))
|
||||
* **Summarize Node:** Turns error when field not found in items into warning ([#11889](https://github.com/n8n-io/n8n/issues/11889)) ([d7dda3f](https://github.com/n8n-io/n8n/commit/d7dda3f5de52925e554455f9f10e51bd173ea856))
|
||||
* **Telegram Node:** New operation sendAndWait ([#12771](https://github.com/n8n-io/n8n/issues/12771)) ([2c58d47](https://github.com/n8n-io/n8n/commit/2c58d47f8eee1f865ecc1eeb89aa20c69c28abae))
|
||||
|
||||
|
||||
|
||||
# [1.76.0](https://github.com/n8n-io/n8n/compare/n8n@1.75.0...n8n@1.76.0) (2025-01-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Align saving behavior in `workflowExecuteAfter` hooks ([#12731](https://github.com/n8n-io/n8n/issues/12731)) ([9d76210](https://github.com/n8n-io/n8n/commit/9d76210a570e025d01d1f6596667abf40fbd8d12))
|
||||
* **core:** AugmentObject should handle the constructor property correctly ([#12744](https://github.com/n8n-io/n8n/issues/12744)) ([36bc164](https://github.com/n8n-io/n8n/commit/36bc164da486f2e2d05091b457b8eea6521ca22e))
|
||||
* **core:** Fix keyboard shortcuts for non-ansi layouts ([#12672](https://github.com/n8n-io/n8n/issues/12672)) ([4c8193f](https://github.com/n8n-io/n8n/commit/4c8193fedc2e3967c9a06c0652483128df509653))
|
||||
* **core:** Fix license CLI commands showing incorrect renewal setting ([#12759](https://github.com/n8n-io/n8n/issues/12759)) ([024ada8](https://github.com/n8n-io/n8n/commit/024ada822c1bc40958e594bb08707cf77d3397ec))
|
||||
* **core:** Fix license initialization failure on startup ([#12737](https://github.com/n8n-io/n8n/issues/12737)) ([ac2f647](https://github.com/n8n-io/n8n/commit/ac2f6476c114f51fafb9b7b66e41e0c87f4a1bf6))
|
||||
* **core:** Recover successful data-less executions ([#12720](https://github.com/n8n-io/n8n/issues/12720)) ([a39b8bd](https://github.com/n8n-io/n8n/commit/a39b8bd32be50c8323e415f820b25b4bcb81d960))
|
||||
* **core:** Remove run data of utility nodes for partial executions v2 ([#12673](https://github.com/n8n-io/n8n/issues/12673)) ([b66a9dc](https://github.com/n8n-io/n8n/commit/b66a9dc8fb6f7b19122cacbb7e2f86b4c921c3fb))
|
||||
* **core:** Sync `hookFunctionsSave` and `hookFunctionsSaveWorker` ([#12740](https://github.com/n8n-io/n8n/issues/12740)) ([d410b8f](https://github.com/n8n-io/n8n/commit/d410b8f5a7e99658e1e8dcb2e02901bd01ce9c59))
|
||||
* **core:** Update isDocker check to return true on kubernetes/containerd ([#12603](https://github.com/n8n-io/n8n/issues/12603)) ([c55dac6](https://github.com/n8n-io/n8n/commit/c55dac66ed97a2317d4c696c3b505790ec5d72fe))
|
||||
* **editor:** Add unicode code points to expression language for emoji ([#12633](https://github.com/n8n-io/n8n/issues/12633)) ([819ebd0](https://github.com/n8n-io/n8n/commit/819ebd058d1d60b3663d92b4a652728da7134a3b))
|
||||
* **editor:** Correct missing whitespace in JSON output ([#12677](https://github.com/n8n-io/n8n/issues/12677)) ([b098b19](https://github.com/n8n-io/n8n/commit/b098b19c7f0e3a9848c3fcfa012999050f2d3c7a))
|
||||
* **editor:** Defer crypto.randomUUID call in CodeNodeEditor ([#12630](https://github.com/n8n-io/n8n/issues/12630)) ([58f6532](https://github.com/n8n-io/n8n/commit/58f6532630bacd288d3c0a79b40150f465898419))
|
||||
* **editor:** Fix Code node bug erasing and overwriting code when switching between nodes ([#12637](https://github.com/n8n-io/n8n/issues/12637)) ([02d953d](https://github.com/n8n-io/n8n/commit/02d953db34ec4e44977a8ca908628b62cca82fde))
|
||||
* **editor:** Fix execution list hover & selection colour in dark mode ([#12628](https://github.com/n8n-io/n8n/issues/12628)) ([95c40c0](https://github.com/n8n-io/n8n/commit/95c40c02cb8fef77cf633cf5aec08e98746cff36))
|
||||
* **editor:** Fix JsonEditor with expressions ([#12739](https://github.com/n8n-io/n8n/issues/12739)) ([56c93ca](https://github.com/n8n-io/n8n/commit/56c93caae026738c1c0bebb4187b238e34a330f6))
|
||||
* **editor:** Fix navbar height flickering during load ([#12738](https://github.com/n8n-io/n8n/issues/12738)) ([a96b3f0](https://github.com/n8n-io/n8n/commit/a96b3f0091798a52bb33107b919b5d8287ba7506))
|
||||
* **editor:** Open chat when executing agent node in canvas v2 ([#12617](https://github.com/n8n-io/n8n/issues/12617)) ([457edd9](https://github.com/n8n-io/n8n/commit/457edd99bb853d8ccf3014605d5823933f3c0bc6))
|
||||
* **editor:** Partial execution of a workflow with manual chat trigger ([#12662](https://github.com/n8n-io/n8n/issues/12662)) ([2f81b29](https://github.com/n8n-io/n8n/commit/2f81b29d341535b512df0aa01b25a91d109f113f))
|
||||
* **editor:** Show connector label above the line when it's straight ([#12622](https://github.com/n8n-io/n8n/issues/12622)) ([c97bd48](https://github.com/n8n-io/n8n/commit/c97bd48a77643b9c2a5d7218e21b957af15cee0b))
|
||||
* **editor:** Show run workflow button when chat trigger has pinned data ([#12616](https://github.com/n8n-io/n8n/issues/12616)) ([da8aafc](https://github.com/n8n-io/n8n/commit/da8aafc0e3a1b5d862f0723d0d53d2c38bcaebc3))
|
||||
* **editor:** Update workflow re-initialization to use query parameter ([#12650](https://github.com/n8n-io/n8n/issues/12650)) ([982131a](https://github.com/n8n-io/n8n/commit/982131a75a32f741c120156826c303989aac189c))
|
||||
* **Execute Workflow Node:** Pass binary data to sub-workflow ([#12635](https://github.com/n8n-io/n8n/issues/12635)) ([e9c152e](https://github.com/n8n-io/n8n/commit/e9c152e369a4c2762bd8e6ad17eaa704bb3771bb))
|
||||
* **Google Gemini Chat Model Node:** Add base URL support for Google Gemini Chat API ([#12643](https://github.com/n8n-io/n8n/issues/12643)) ([14f4bc7](https://github.com/n8n-io/n8n/commit/14f4bc769027789513808b4000444edf99dc5d1c))
|
||||
* **GraphQL Node:** Change default request format to json instead of graphql ([#11346](https://github.com/n8n-io/n8n/issues/11346)) ([c7c122f](https://github.com/n8n-io/n8n/commit/c7c122f9173df824cc1b5ab864333bffd0d31f82))
|
||||
* **Jira Software Node:** Get custom fields(RLC) in update operation for server deployment type ([#12719](https://github.com/n8n-io/n8n/issues/12719)) ([353df79](https://github.com/n8n-io/n8n/commit/353df7941117e20547cd4f3fc514979a54619720))
|
||||
* **n8n Form Node:** Remove the ability to change the formatting of dates ([#12666](https://github.com/n8n-io/n8n/issues/12666)) ([14904ff](https://github.com/n8n-io/n8n/commit/14904ff77951fef23eb789a43947492a4cd3fa20))
|
||||
* **OpenAI Chat Model Node:** Fix loading of custom models when using custom credential URL ([#12634](https://github.com/n8n-io/n8n/issues/12634)) ([7cc553e](https://github.com/n8n-io/n8n/commit/7cc553e3b277a16682bfca1ea08cb98178e38580))
|
||||
* **OpenAI Chat Model Node:** Restore default model value ([#12745](https://github.com/n8n-io/n8n/issues/12745)) ([d1b6692](https://github.com/n8n-io/n8n/commit/d1b6692736182fa2eab768ba3ad0adb8504ebbbd))
|
||||
* **Postgres Chat Memory Node:** Do not terminate the connection pool ([#12674](https://github.com/n8n-io/n8n/issues/12674)) ([e7f00bc](https://github.com/n8n-io/n8n/commit/e7f00bcb7f2dce66ca07a9322d50f96356c1a43d))
|
||||
* **Postgres Node:** Allow using composite key in upsert queries ([#12639](https://github.com/n8n-io/n8n/issues/12639)) ([83ce3a9](https://github.com/n8n-io/n8n/commit/83ce3a90963ba76601234f4314363a8ccc310f0f))
|
||||
* **Wait Node:** Fix for hasNextPage in waiting forms ([#12636](https://github.com/n8n-io/n8n/issues/12636)) ([652b8d1](https://github.com/n8n-io/n8n/commit/652b8d170b9624d47b5f2d8d679c165cc14ea548))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add credential only node for Microsoft Azure Monitor ([#12645](https://github.com/n8n-io/n8n/issues/12645)) ([6ef8882](https://github.com/n8n-io/n8n/commit/6ef8882a108c672ab097c9dd1c590d4e9e7f3bcc))
|
||||
* Add Miro credential only node ([#12746](https://github.com/n8n-io/n8n/issues/12746)) ([5b29086](https://github.com/n8n-io/n8n/commit/5b29086e2f9b7f638fac4440711f673438e57492))
|
||||
* Add SSM endpoint to AWS credentials ([#12212](https://github.com/n8n-io/n8n/issues/12212)) ([565c7b8](https://github.com/n8n-io/n8n/commit/565c7b8b9cfd3e10f6a2c60add96fea4c4d95d33))
|
||||
* **core:** Enable task runner by default ([#12726](https://github.com/n8n-io/n8n/issues/12726)) ([9e2a01a](https://github.com/n8n-io/n8n/commit/9e2a01aeaf36766a1cf7a1d9a4d6e02f45739bd3))
|
||||
* **editor:** Force final canvas v2 migration and remove switcher from UI ([#12717](https://github.com/n8n-io/n8n/issues/12717)) ([29335b9](https://github.com/n8n-io/n8n/commit/29335b9b6acf97c817bea70688e8a2786fbd8889))
|
||||
* **editor:** VariablesView Reskin - Add Filters for missing values ([#12611](https://github.com/n8n-io/n8n/issues/12611)) ([1eeb788](https://github.com/n8n-io/n8n/commit/1eeb788d327287d21eab7ad6f2156453ab7642c7))
|
||||
* **Jira Software Node:** Personal Access Token credential type ([#11038](https://github.com/n8n-io/n8n/issues/11038)) ([1c7a38f](https://github.com/n8n-io/n8n/commit/1c7a38f6bab108daa47401cd98c185590bf299a8))
|
||||
* **n8n Form Trigger Node:** Form Improvements ([#12590](https://github.com/n8n-io/n8n/issues/12590)) ([f167578](https://github.com/n8n-io/n8n/commit/f167578b3251e553a4d000e731e1bb60348916ad))
|
||||
* Synchronize deletions when pulling from source control ([#12170](https://github.com/n8n-io/n8n/issues/12170)) ([967ee4b](https://github.com/n8n-io/n8n/commit/967ee4b89b94b92fc3955c56bf4c9cca0bd64eac))
|
||||
|
||||
|
||||
|
||||
# [1.75.0](https://github.com/n8n-io/n8n/compare/n8n@1.74.0...n8n@1.75.0) (2025-01-15)
|
||||
|
||||
|
||||
|
|
81
cypress/composables/webhooks.ts
Normal file
81
cypress/composables/webhooks.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
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);
|
||||
|
||||
if (authentication) {
|
||||
cy.getByTestId('parameter-input-authentication').click();
|
||||
getVisibleSelect().find('.option-headline').contains(authentication).click();
|
||||
}
|
||||
|
||||
if (responseCode) {
|
||||
cy.get('.param-options').click();
|
||||
getVisibleSelect().contains('Response Code').click();
|
||||
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,6 +1,7 @@
|
|||
import { getManualChatModal } from './modals/chat-modal';
|
||||
import { clickGetBackToCanvas, getParameterInputByName } from './ndv';
|
||||
import { ROUTES } from '../constants';
|
||||
import type { OpenContextMenuOptions } from '../types';
|
||||
|
||||
/**
|
||||
* Types
|
||||
|
@ -24,7 +25,36 @@ export type EndpointType =
|
|||
* Getters
|
||||
*/
|
||||
|
||||
export function getAddInputEndpointByType(nodeName: string, endpointType: EndpointType) {
|
||||
export function getCanvas() {
|
||||
return cy.getByTestId('canvas');
|
||||
}
|
||||
|
||||
export function getCanvasPane() {
|
||||
return cy.ifCanvasVersion(
|
||||
() => cy.getByTestId('node-view-background'),
|
||||
() => 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.ifCanvasVersion(
|
||||
() => cy.get(`.add-input-endpoint[data-endpoint-name="${nodeName}"]`),
|
||||
() =>
|
||||
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.ifCanvasVersion(
|
||||
() =>
|
||||
cy.get(
|
||||
|
@ -37,6 +67,36 @@ export function getAddInputEndpointByType(nodeName: string, endpointType: Endpoi
|
|||
);
|
||||
}
|
||||
|
||||
export function getOutputHandle(nodeName: string) {
|
||||
return cy.ifCanvasVersion(
|
||||
() => cy.get(`.add-output-endpoint[data-endpoint-name="${nodeName}"]`),
|
||||
() => cy.get(`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"]`),
|
||||
);
|
||||
}
|
||||
|
||||
export function getOutputPlusHandle(nodeName: string) {
|
||||
return cy.ifCanvasVersion(
|
||||
() => cy.get(`.add-output-endpoint[data-endpoint-name="${nodeName}"]`),
|
||||
() =>
|
||||
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.ifCanvasVersion(
|
||||
() =>
|
||||
cy.get(
|
||||
`.add-output-endpoint[data-jtk-scope-${endpointType}][data-endpoint-name="${nodeName}"]`,
|
||||
),
|
||||
() =>
|
||||
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');
|
||||
}
|
||||
|
@ -60,6 +120,13 @@ export function getNodeByName(name: string) {
|
|||
);
|
||||
}
|
||||
|
||||
export function getNodeRenderedTypeByName(name: string) {
|
||||
return cy.ifCanvasVersion(
|
||||
() => getNodeByName(name),
|
||||
() => getNodeByName(name).find('[data-canvas-node-render-type]'),
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorkflowHistoryCloseButton() {
|
||||
return cy.getByTestId('workflow-history-close-button');
|
||||
}
|
||||
|
@ -85,6 +152,12 @@ export function getConnectionBySourceAndTarget(source: string, target: string) {
|
|||
);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
@ -94,10 +167,7 @@ export function getNodeCreatorPlusButton() {
|
|||
}
|
||||
|
||||
export function getCanvasNodes() {
|
||||
return cy.ifCanvasVersion(
|
||||
() => cy.getByTestId('canvas-node'),
|
||||
() => cy.getByTestId('canvas-node').not('[data-node-type="n8n-nodes-internal.addNodes"]'),
|
||||
);
|
||||
return cy.getByTestId('canvas-node');
|
||||
}
|
||||
|
||||
export function getCanvasNodeByName(nodeName: string) {
|
||||
|
@ -157,7 +227,7 @@ function connectNodeToParent(
|
|||
parentNodeName: string,
|
||||
exactMatch = false,
|
||||
) {
|
||||
getAddInputEndpointByType(parentNodeName, endpointType).click({ force: true });
|
||||
getInputPlusHandleByType(parentNodeName, endpointType).click({ force: true });
|
||||
if (exactMatch) {
|
||||
getNodeCreatorItems()
|
||||
.contains(new RegExp('^' + nodeName + '$', 'g'))
|
||||
|
@ -257,3 +327,34 @@ 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 });
|
||||
}
|
||||
|
||||
cy.ifCanvasVersion(
|
||||
() => {},
|
||||
() => {
|
||||
getContextMenu().should('be.visible');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function clickContextMenuAction(action: string) {
|
||||
getContextMenuAction(action).click();
|
||||
}
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
import { getCanvasNodes } from '../composables/workflow';
|
||||
import {
|
||||
SCHEDULE_TRIGGER_NODE_NAME,
|
||||
CODE_NODE_NAME,
|
||||
SET_NODE_NAME,
|
||||
EDIT_FIELDS_SET_NODE_NAME,
|
||||
MANUAL_TRIGGER_NODE_NAME,
|
||||
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
|
||||
} from '../constants';
|
||||
import { MessageBox as MessageBoxClass } from '../pages/modals/message-box';
|
||||
import { NDV } from '../pages/ndv';
|
||||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||
|
||||
// Suite-specific constants
|
||||
const CODE_NODE_NEW_NAME = 'Something else';
|
||||
|
||||
const WorkflowPage = new WorkflowPageClass();
|
||||
const messageBox = new MessageBoxClass();
|
||||
const ndv = new NDV();
|
||||
|
@ -20,40 +19,6 @@ describe('Undo/Redo', () => {
|
|||
WorkflowPage.actions.visit();
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix redo connections
|
||||
it('should undo/redo adding node in the middle', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeBetweenNodes(
|
||||
SCHEDULE_TRIGGER_NODE_NAME,
|
||||
CODE_NODE_NAME,
|
||||
SET_NODE_NAME,
|
||||
);
|
||||
WorkflowPage.actions.zoomToFit();
|
||||
WorkflowPage.getters.canvasNodeByName('Code').then(($codeNode) => {
|
||||
const cssLeft = parseInt($codeNode.css('left'));
|
||||
const cssTop = parseInt($codeNode.css('top'));
|
||||
|
||||
WorkflowPage.actions.hitUndo();
|
||||
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', 1);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
WorkflowPage.actions.hitRedo();
|
||||
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', 3);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 2);
|
||||
// Last node should be added back to original position
|
||||
WorkflowPage.getters
|
||||
.canvasNodeByName('Code')
|
||||
.should('have.css', 'left', cssLeft + 'px')
|
||||
.should('have.css', 'top', cssTop + 'px');
|
||||
});
|
||||
});
|
||||
|
||||
it('should undo/redo deleting node using context menu', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
|
@ -115,32 +80,58 @@ describe('Undo/Redo', () => {
|
|||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix moving of nodes via e2e tests
|
||||
it('should undo/redo moving nodes', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).then(($node) => {
|
||||
const initialPosition = $node.position();
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], { clickToFinish: true });
|
||||
|
||||
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).then(($node) => {
|
||||
const cssLeft = parseInt($node.css('left'));
|
||||
const cssTop = parseInt($node.css('top'));
|
||||
expect(cssLeft).to.be.greaterThan(initialPosition.left);
|
||||
expect(cssTop).to.be.greaterThan(initialPosition.top);
|
||||
WorkflowPage.actions.zoomToFit();
|
||||
|
||||
getCanvasNodes()
|
||||
.last()
|
||||
.then(($node) => {
|
||||
const { x: x1, y: y1 } = $node[0].getBoundingClientRect();
|
||||
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
|
||||
clickToFinish: true,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
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();
|
||||
WorkflowPage.getters
|
||||
.canvasNodeByName(CODE_NODE_NAME)
|
||||
.should('have.css', 'left', `${initialPosition.left}px`)
|
||||
.should('have.css', 'top', `${initialPosition.top}px`);
|
||||
|
||||
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();
|
||||
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).then(($node) => {
|
||||
const cssLeft = parseInt($node.css('left'));
|
||||
const cssTop = parseInt($node.css('top'));
|
||||
expect(cssLeft).to.be.greaterThan(initialPosition.left);
|
||||
expect(cssTop).to.be.greaterThan(initialPosition.top);
|
||||
|
||||
getCanvasNodes()
|
||||
.last()
|
||||
.then(($node) => {
|
||||
const { x: x4, y: y4 } = $node[0].getBoundingClientRect();
|
||||
expect(x4).to.be.greaterThan(x1);
|
||||
expect(y4).to.be.greaterThan(y1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -155,17 +146,6 @@ describe('Undo/Redo', () => {
|
|||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
// FIXME: Canvas V2: Fix disconnecting by moving
|
||||
it('should undo/redo deleting a connection by moving it away', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
cy.drag('.rect-input-endpoint.jtk-endpoint-connected', [0, -100]);
|
||||
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);
|
||||
|
@ -204,23 +184,6 @@ describe('Undo/Redo', () => {
|
|||
WorkflowPage.getters.disabledNodes().should('have.length', 2);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix undo renaming node
|
||||
it('should undo/redo renaming node using keyboard shortcut', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_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(CODE_NODE_NEW_NAME);
|
||||
cy.get('body').type('{enter}');
|
||||
WorkflowPage.actions.hitUndo();
|
||||
cy.get('body').type('{esc}');
|
||||
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).should('exist');
|
||||
WorkflowPage.actions.hitRedo();
|
||||
cy.get('body').type('{esc}');
|
||||
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NEW_NAME).should('exist');
|
||||
});
|
||||
|
||||
it('should undo/redo duplicating a node', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
|
@ -243,77 +206,6 @@ describe('Undo/Redo', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Figure out why moving doesn't work from e2e
|
||||
it('should undo/redo multiple steps', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
// WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
WorkflowPage.actions.zoomToFit();
|
||||
|
||||
// Disable last node
|
||||
WorkflowPage.getters.canvasNodes().last().click();
|
||||
WorkflowPage.actions.hitDisableNodeShortcut();
|
||||
|
||||
// Move first one
|
||||
WorkflowPage.actions
|
||||
.getNodePosition(WorkflowPage.getters.canvasNodes().first())
|
||||
.then((initialPosition) => {
|
||||
WorkflowPage.getters.canvasNodes().first().click();
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
|
||||
clickToFinish: true,
|
||||
});
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
.first()
|
||||
.then(($node) => {
|
||||
const cssLeft = parseInt($node.css('left'));
|
||||
const cssTop = parseInt($node.css('top'));
|
||||
expect(cssLeft).to.be.greaterThan(initialPosition.left);
|
||||
expect(cssTop).to.be.greaterThan(initialPosition.top);
|
||||
});
|
||||
|
||||
// Delete the set node
|
||||
WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click().click();
|
||||
cy.get('body').type('{backspace}');
|
||||
|
||||
// First undo: Should return deleted node
|
||||
WorkflowPage.actions.hitUndo();
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 4);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 3);
|
||||
// Second undo: Should move first node to it's original position
|
||||
WorkflowPage.actions.hitUndo();
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
.first()
|
||||
.should('have.css', 'left', `${initialPosition.left}px`)
|
||||
.should('have.css', 'top', `${initialPosition.top}px`);
|
||||
// Third undo: Should enable last node
|
||||
WorkflowPage.actions.hitUndo();
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 0);
|
||||
|
||||
// First redo: Should disable last node
|
||||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 1);
|
||||
// Second redo: Should move the first node
|
||||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
.first()
|
||||
.then(($node) => {
|
||||
const cssLeft = parseInt($node.css('left'));
|
||||
const cssTop = parseInt($node.css('top'));
|
||||
expect(cssLeft).to.be.greaterThan(initialPosition.left);
|
||||
expect(cssTop).to.be.greaterThan(initialPosition.top);
|
||||
});
|
||||
// Third redo: Should delete the Set node
|
||||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 3);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 2);
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
|
|
|
@ -129,7 +129,7 @@ describe('Inline expression editor', () => {
|
|||
|
||||
// Run workflow
|
||||
ndv.actions.close();
|
||||
WorkflowPage.actions.executeNode('No Operation', { anchor: 'topLeft' });
|
||||
WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' });
|
||||
WorkflowPage.actions.openNode('Hacker News');
|
||||
WorkflowPage.actions.openInlineExpressionEditor();
|
||||
|
||||
|
|
|
@ -4,9 +4,9 @@ import {
|
|||
CODE_NODE_NAME,
|
||||
SCHEDULE_TRIGGER_NODE_NAME,
|
||||
EDIT_FIELDS_SET_NODE_NAME,
|
||||
IF_NODE_NAME,
|
||||
HTTP_REQUEST_NODE_NAME,
|
||||
} from './../constants';
|
||||
import { getCanvasPane } from '../composables/workflow';
|
||||
import { successToast } from '../pages/notifications';
|
||||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||
|
||||
|
@ -16,64 +16,12 @@ describe('Canvas Actions', () => {
|
|||
WorkflowPage.actions.visit();
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Missing execute button if no nodes
|
||||
it('should render canvas', () => {
|
||||
WorkflowPage.getters.nodeViewRoot().should('be.visible');
|
||||
WorkflowPage.getters.canvasPlusButton().should('be.visible');
|
||||
WorkflowPage.getters.zoomToFitButton().should('be.visible');
|
||||
WorkflowPage.getters.zoomInButton().should('be.visible');
|
||||
WorkflowPage.getters.zoomOutButton().should('be.visible');
|
||||
WorkflowPage.getters.executeWorkflowButton().should('be.visible');
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix changing of connection
|
||||
it('should connect and disconnect a simple node', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
WorkflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||
|
||||
WorkflowPage.getters.nodeViewBackground().click(600, 400, { force: true });
|
||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
|
||||
// Change connection from Set to Set1
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('input', EDIT_FIELDS_SET_NODE_NAME),
|
||||
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||
);
|
||||
|
||||
WorkflowPage.getters
|
||||
.getConnectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, `${EDIT_FIELDS_SET_NODE_NAME}1`)
|
||||
.should('be.visible');
|
||||
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||
// Disconnect Set1
|
||||
cy.drag(
|
||||
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||
[-200, 100],
|
||||
);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
|
||||
it('should add first step', () => {
|
||||
WorkflowPage.getters.canvasPlusButton().should('be.visible');
|
||||
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should add a node via plus endpoint drag', () => {
|
||||
WorkflowPage.getters.canvasPlusButton().should('be.visible');
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, true);
|
||||
|
||||
cy.drag(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', SCHEDULE_TRIGGER_NODE_NAME),
|
||||
[100, 100],
|
||||
);
|
||||
|
||||
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
|
||||
WorkflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false);
|
||||
WorkflowPage.getters.nodeViewBackground().click({ force: true });
|
||||
});
|
||||
|
||||
it('should add a connected node using plus endpoint', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
|
@ -116,7 +64,7 @@ describe('Canvas Actions', () => {
|
|||
it('should add disconnected node if nothing is selected', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
// Deselect nodes
|
||||
WorkflowPage.getters.nodeView().click({ force: true });
|
||||
getCanvasPane().click({ force: true });
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
|
@ -166,15 +114,6 @@ describe('Canvas Actions', () => {
|
|||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix disconnecting of connection by dragging it
|
||||
it('should delete a connection by moving it away from endpoint', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
cy.drag(WorkflowPage.getters.getEndpointSelector('input', CODE_NODE_NAME), [0, -100]);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
|
||||
describe('Node hover actions', () => {
|
||||
it('should execute node', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
|
@ -184,7 +123,11 @@ describe('Canvas Actions', () => {
|
|||
.last()
|
||||
.findChildByTestId('execute-node-button')
|
||||
.click({ force: true });
|
||||
|
||||
successToast().should('have.length', 1);
|
||||
|
||||
WorkflowPage.actions.executeNode(CODE_NODE_NAME);
|
||||
|
||||
successToast().should('have.length', 2);
|
||||
successToast().should('contain.text', 'Node executed successfully');
|
||||
});
|
||||
|
@ -235,7 +178,6 @@ describe('Canvas Actions', () => {
|
|||
WorkflowPage.getters.selectedNodes().should('have.length', 0);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Selection via arrow keys is broken
|
||||
it('should select nodes using arrow keys', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
|
@ -259,7 +201,6 @@ describe('Canvas Actions', () => {
|
|||
);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Selection via shift and arrow keys is broken
|
||||
it('should select nodes using shift and arrow keys', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
|
@ -268,31 +209,4 @@ describe('Canvas Actions', () => {
|
|||
cy.get('body').type('{shift}', { release: false }).type('{leftArrow}');
|
||||
WorkflowPage.getters.selectedNodes().should('have.length', 2);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix select & deselect
|
||||
it('should not break lasso selection when dragging node action buttons', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
.last()
|
||||
.findChildByTestId('execute-node-button')
|
||||
.as('executeNodeButton');
|
||||
cy.drag('@executeNodeButton', [200, 200]);
|
||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix select & deselect
|
||||
it('should not break lasso selection with multiple clicks on node action buttons', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||
WorkflowPage.getters.canvasNodes().last().as('lastNode');
|
||||
cy.get('@lastNode').findChildByTestId('execute-node-button').as('executeNodeButton');
|
||||
for (let i = 0; i < 20; i++) {
|
||||
cy.get('@lastNode').realHover();
|
||||
cy.get('@executeNodeButton').should('be.visible');
|
||||
cy.get('@executeNodeButton').realTouch();
|
||||
cy.getByTestId('execute-workflow-button').realHover();
|
||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,9 +7,17 @@ import {
|
|||
SWITCH_NODE_NAME,
|
||||
MERGE_NODE_NAME,
|
||||
} from './../constants';
|
||||
import {
|
||||
clickContextMenuAction,
|
||||
getCanvasNodeByName,
|
||||
getCanvasNodes,
|
||||
getConnectionBySourceAndTarget,
|
||||
getConnectionLabelBySourceAndTarget,
|
||||
getOutputPlusHandle,
|
||||
openContextMenu,
|
||||
} from '../composables/workflow';
|
||||
import { NDV, WorkflowExecutionsTab } from '../pages';
|
||||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||
import { isCanvasV2 } from '../utils/workflowUtils';
|
||||
|
||||
const WorkflowPage = new WorkflowPageClass();
|
||||
const ExecutionsTab = new WorkflowExecutionsTab();
|
||||
|
@ -20,8 +28,6 @@ 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 PINCH_ZOOM_IN_FACTOR = 1.05702;
|
||||
const PINCH_ZOOM_OUT_FACTOR = 0.946058;
|
||||
const RENAME_NODE_NAME = 'Something else';
|
||||
const RENAME_NODE_NAME2 = 'Something different';
|
||||
|
||||
|
@ -41,27 +47,52 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
|
||||
NDVDialog.actions.close();
|
||||
for (let i = 0; i < desiredOutputs; i++) {
|
||||
WorkflowPage.getters.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i).click({ force: true });
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
WorkflowPage.getters
|
||||
.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i)
|
||||
.click({ force: true });
|
||||
},
|
||||
() => {
|
||||
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 });
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
WorkflowPage.getters.canvasNodePlusEndpointByName(`${EDIT_FIELDS_SET_NODE_NAME}3`).click();
|
||||
},
|
||||
() => {
|
||||
getOutputPlusHandle(`${EDIT_FIELDS_SET_NODE_NAME}3`).click();
|
||||
},
|
||||
);
|
||||
WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, false);
|
||||
WorkflowPage.actions.saveWorkflowOnButtonClick();
|
||||
cy.reload();
|
||||
cy.waitForLoad();
|
||||
// Make sure outputless switch was connected correctly
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
WorkflowPage.getters
|
||||
.getConnectionBetweenNodes(`${EDIT_FIELDS_SET_NODE_NAME}3`, `${SWITCH_NODE_NAME}1`)
|
||||
.should('exist');
|
||||
},
|
||||
() => {
|
||||
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 : ''}`;
|
||||
WorkflowPage.getters
|
||||
.getConnectionBetweenNodes(`${SWITCH_NODE_NAME}`, setName)
|
||||
.should('exist');
|
||||
|
||||
getConnectionBySourceAndTarget(`${SWITCH_NODE_NAME}`, setName).should('exist');
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -84,6 +115,8 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
);
|
||||
|
||||
// Connect Set1 and Set2 to merge
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME),
|
||||
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0),
|
||||
|
@ -92,6 +125,19 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
WorkflowPage.getters.getEndpointSelector('plus', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
|
||||
);
|
||||
},
|
||||
() => {
|
||||
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(
|
||||
|
@ -117,10 +163,22 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
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
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.get('[data-label="2 items"]').should('be.visible'),
|
||||
() => cy.getByTestId('canvas-node-output-handle').contains('2 items').should('be.visible'),
|
||||
() =>
|
||||
getConnectionLabelBySourceAndTarget(`${EDIT_FIELDS_SET_NODE_NAME}1`, MERGE_NODE_NAME)
|
||||
.contains('2 items')
|
||||
.should('be.visible'),
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -144,7 +202,10 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.get('.plus-draggable-endpoint').should('have.class', 'ep-success'),
|
||||
() => cy.getByTestId('canvas-handle-plus').should('have.attr', 'data-plus-type', 'success'),
|
||||
() =>
|
||||
cy
|
||||
.getByTestId('canvas-handle-plus-wrapper')
|
||||
.should('have.attr', 'data-plus-type', 'success'),
|
||||
);
|
||||
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
|
@ -212,8 +273,8 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
cy.wait(500);
|
||||
WorkflowPage.actions.selectAllFromContextMenu();
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('delete');
|
||||
openContextMenu();
|
||||
clickContextMenuAction('delete');
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 0);
|
||||
});
|
||||
|
||||
|
@ -228,41 +289,43 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
cy.wait(500);
|
||||
WorkflowPage.actions.selectAllFromContextMenu();
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('delete');
|
||||
openContextMenu();
|
||||
clickContextMenuAction('delete');
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 0);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Figure out how to test moving of the node
|
||||
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();
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
|
||||
getCanvasNodes()
|
||||
.last()
|
||||
.then(($node) => {
|
||||
const { left, top } = $node.position();
|
||||
const { x: x1, y: y1 } = $node[0].getBoundingClientRect();
|
||||
|
||||
if (isCanvasV2()) {
|
||||
cy.drag('.vue-flow__node', [300, 300], {
|
||||
realMouse: true,
|
||||
});
|
||||
} else {
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
|
||||
clickToFinish: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
cy.drag(getCanvasNodes().last(), [50, 150], {
|
||||
realMouse: true,
|
||||
abs: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
getCanvasNodes()
|
||||
.last()
|
||||
.then(($node) => {
|
||||
const { left: newLeft, top: newTop } = $node.position();
|
||||
expect(newLeft).to.be.greaterThan(left);
|
||||
expect(newTop).to.be.greaterThan(top);
|
||||
const { x: x2, y: y2 } = $node[0].getBoundingClientRect();
|
||||
expect(x2).to.be.greaterThan(x1);
|
||||
expect(y2).to.be.greaterThan(y1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -304,26 +367,6 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
zoomAndCheck('zoomOut', ZOOM_OUT_X2_FACTOR);
|
||||
});
|
||||
|
||||
it('should zoom using scroll or pinch gesture', () => {
|
||||
WorkflowPage.actions.pinchToZoom(1, 'zoomIn');
|
||||
|
||||
// V2 Canvas is using the same zoom factor for both pinch and scroll
|
||||
cy.ifCanvasVersion(
|
||||
() => checkZoomLevel(PINCH_ZOOM_IN_FACTOR),
|
||||
() => checkZoomLevel(ZOOM_IN_X1_FACTOR),
|
||||
);
|
||||
|
||||
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
|
||||
checkZoomLevel(1); // Zoom in 1x + Zoom out 1x should reset to default (=1)
|
||||
|
||||
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
|
||||
|
||||
cy.ifCanvasVersion(
|
||||
() => checkZoomLevel(PINCH_ZOOM_OUT_FACTOR),
|
||||
() => checkZoomLevel(ZOOM_OUT_X1_FACTOR),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset zoom', () => {
|
||||
WorkflowPage.getters.resetZoomButton().should('not.exist');
|
||||
WorkflowPage.getters.zoomInButton().click();
|
||||
|
@ -369,7 +412,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
WorkflowPage.actions.hitDisableNodeShortcut();
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 0);
|
||||
WorkflowPage.actions.deselectAll();
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
getCanvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
WorkflowPage.actions.hitDisableNodeShortcut();
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 1);
|
||||
WorkflowPage.actions.hitSelectAll();
|
||||
|
@ -378,19 +421,19 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
|
||||
// Context menu
|
||||
WorkflowPage.actions.hitSelectAll();
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('toggle_activation');
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 0);
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
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();
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('toggle_activation');
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 1);
|
||||
WorkflowPage.actions.hitSelectAll();
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('toggle_activation');
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 2);
|
||||
});
|
||||
|
@ -466,7 +509,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||
});
|
||||
// FIXME: Canvas V2: Credentials should show issue on the first open
|
||||
|
||||
it('should remove unknown credentials on pasting workflow', () => {
|
||||
cy.fixture('workflow-with-unknown-credentials.json').then((data) => {
|
||||
cy.get('body').paste(JSON.stringify(data));
|
||||
|
@ -478,35 +521,4 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
NDVDialog.actions.close();
|
||||
});
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Unknown nodes should still render connection endpoints
|
||||
it('should render connections correctly if unkown nodes are present', () => {
|
||||
const unknownNodeName = 'Unknown node';
|
||||
cy.createFixtureWorkflow('workflow-with-unknown-nodes.json', 'Unknown nodes');
|
||||
|
||||
WorkflowPage.getters.canvasNodeByName(`${unknownNodeName} 1`).should('exist');
|
||||
WorkflowPage.getters.canvasNodeByName(`${unknownNodeName} 2`).should('exist');
|
||||
WorkflowPage.actions.zoomToFit();
|
||||
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', `${unknownNodeName} 1`),
|
||||
WorkflowPage.getters.getEndpointSelector('input', EDIT_FIELDS_SET_NODE_NAME),
|
||||
);
|
||||
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', `${unknownNodeName} 2`),
|
||||
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||
);
|
||||
|
||||
WorkflowPage.actions.executeWorkflow();
|
||||
cy.contains('Unrecognized node type').should('be.visible');
|
||||
|
||||
WorkflowPage.actions.deselectAll();
|
||||
WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 1`);
|
||||
WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 2`);
|
||||
|
||||
WorkflowPage.actions.executeWorkflow();
|
||||
|
||||
cy.contains('Unrecognized node type').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { simpleWebhookCall, waitForWebhook } from './16-webhook-node.cy';
|
||||
import {
|
||||
HTTP_REQUEST_NODE_NAME,
|
||||
MANUAL_TRIGGER_NODE_NAME,
|
||||
|
@ -109,36 +106,6 @@ describe('Data pinning', () => {
|
|||
ndv.getters.outputTbodyCell(1, 0).should('include.text', 1);
|
||||
});
|
||||
|
||||
it('Should be able to pin data from canvas (context menu or shortcut)', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
|
||||
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
workflowPage.actions.openContextMenu(EDIT_FIELDS_SET_NODE_NAME, { method: 'overflow-button' });
|
||||
workflowPage.getters
|
||||
.contextMenuAction('toggle_pin')
|
||||
.parent()
|
||||
.should('have.class', 'is-disabled');
|
||||
|
||||
cy.get('body').type('{esc}');
|
||||
|
||||
// Unpin using context menu
|
||||
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||
ndv.actions.setPinnedData([{ test: 1 }]);
|
||||
ndv.actions.close();
|
||||
workflowPage.actions.pinNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||
ndv.getters.nodeOutputHint().should('exist');
|
||||
ndv.actions.close();
|
||||
|
||||
// Unpin using shortcut
|
||||
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||
ndv.actions.setPinnedData([{ test: 1 }]);
|
||||
ndv.actions.close();
|
||||
workflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click();
|
||||
workflowPage.actions.hitPinNodeShortcut();
|
||||
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||
ndv.getters.nodeOutputHint().should('exist');
|
||||
});
|
||||
|
||||
it('Should show an error when maximum pin data size is exceeded', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
|
||||
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
|
||||
|
@ -217,32 +184,6 @@ describe('Data pinning', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should show pinned data tooltip', () => {
|
||||
const { callEndpoint } = simpleWebhookCall({
|
||||
method: 'GET',
|
||||
webhookPath: nanoid(),
|
||||
executeNow: false,
|
||||
});
|
||||
|
||||
ndv.actions.close();
|
||||
workflowPage.actions.executeWorkflow();
|
||||
cy.wait(waitForWebhook);
|
||||
|
||||
// hide other visible popper on workflow execute button
|
||||
workflowPage.getters.canvasNodes().eq(0).click();
|
||||
|
||||
callEndpoint((response) => {
|
||||
expect(response.status).to.eq(200);
|
||||
getVisiblePopper().should('have.length', 1);
|
||||
getVisiblePopper()
|
||||
.eq(0)
|
||||
.should(
|
||||
'have.text',
|
||||
'You can pin this output instead of waiting for a test event. Open node to do so.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show pinned data tooltip', () => {
|
||||
cy.createFixtureWorkflow('Pinned_webhook_node.json', 'Test');
|
||||
workflowPage.actions.executeWorkflow();
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { simpleWebhookCall, waitForWebhook } from '../composables/webhooks';
|
||||
import { BACKEND_BASE_URL, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
|
||||
import { WorkflowPage, NDV, CredentialsModal } from '../pages';
|
||||
import { cowBase64 } from '../support/binaryTestFiles';
|
||||
|
@ -9,81 +10,6 @@ const workflowPage = new WorkflowPage();
|
|||
const ndv = new NDV();
|
||||
const credentialsModal = new CredentialsModal();
|
||||
|
||||
export const waitForWebhook = 500;
|
||||
|
||||
interface SimpleWebhookCallOptions {
|
||||
method: string;
|
||||
webhookPath: string;
|
||||
responseCode?: number;
|
||||
respondWith?: string;
|
||||
executeNow?: boolean;
|
||||
responseData?: string;
|
||||
authentication?: string;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (authentication) {
|
||||
cy.getByTestId('parameter-input-authentication').click();
|
||||
getVisibleSelect().find('.option-headline').contains(authentication).click();
|
||||
}
|
||||
|
||||
if (responseCode) {
|
||||
cy.get('.param-options').click();
|
||||
getVisibleSelect().contains('Response Code').click();
|
||||
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 = (cb: (response: Cypress.Response<unknown>) => void) => {
|
||||
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then(cb);
|
||||
};
|
||||
|
||||
if (executeNow) {
|
||||
ndv.actions.execute();
|
||||
cy.wait(waitForWebhook);
|
||||
|
||||
callEndpoint((response) => {
|
||||
expect(response.status).to.eq(200);
|
||||
ndv.getters.outputPanel().contains('headers');
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
callEndpoint,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Webhook Trigger node', () => {
|
||||
beforeEach(() => {
|
||||
workflowPage.actions.visit();
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||
|
||||
const WorkflowPage = new WorkflowPageClass();
|
||||
|
||||
describe('PAY-1858 context menu', () => {
|
||||
it('can use context menu on saved workflow', () => {
|
||||
WorkflowPage.actions.visit();
|
||||
cy.createFixtureWorkflow('Test_workflow_filter.json', 'test');
|
||||
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 5);
|
||||
WorkflowPage.actions.deleteNodeFromContextMenu('Then');
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 4);
|
||||
|
||||
WorkflowPage.actions.hitSaveWorkflow();
|
||||
|
||||
cy.reload();
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 4);
|
||||
WorkflowPage.actions.deleteNodeFromContextMenu('Code');
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 3);
|
||||
});
|
||||
});
|
|
@ -214,91 +214,6 @@ describe('Execution', () => {
|
|||
workflowPage.getters.clearExecutionDataButton().should('not.exist');
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Webhook should show waiting state but it doesn't
|
||||
it('should test webhook workflow stop', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
successToast().should('be.visible');
|
||||
clearNotifications();
|
||||
|
||||
workflowPage.getters.stopExecutionButton().click();
|
||||
// Check canvas nodes after 1st step (workflow passed the manual trigger node
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Webhook')
|
||||
.within(() => cy.get('.fa-check'))
|
||||
.should('exist');
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Wait')
|
||||
.within(() => cy.get('.fa-check').should('not.exist'));
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Wait')
|
||||
.within(() => cy.get('.fa-sync-alt'))
|
||||
.should('exist');
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Set')
|
||||
.within(() => cy.get('.fa-check').should('not.exist'));
|
||||
|
||||
// Check canvas nodes after workflow stopped
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Webhook')
|
||||
.within(() => cy.get('.fa-check'))
|
||||
.should('exist');
|
||||
|
||||
if (isCanvasV2()) {
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Wait')
|
||||
.within(() => cy.get('.fa-sync-alt').should('not.exist'));
|
||||
} else {
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Wait')
|
||||
.within(() => cy.get('.fa-sync-alt').should('not.be.visible'));
|
||||
}
|
||||
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Set')
|
||||
.within(() => cy.get('.fa-check').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');
|
||||
});
|
||||
|
||||
describe('execution preview', () => {
|
||||
it('when deleting the last execution, it should show empty state', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
|
||||
|
@ -312,8 +227,11 @@ describe('Execution', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Missing pinned states for `edge-label-wrapper`
|
||||
describe('connections should be colored differently for pinned data', () => {
|
||||
/**
|
||||
* @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();
|
||||
|
@ -634,45 +552,4 @@ describe('Execution', () => {
|
|||
|
||||
errorToast().should('contain', 'Problem in node ‘Telegram‘');
|
||||
});
|
||||
|
||||
it('should not show pinned data in production execution', () => {
|
||||
cy.createFixtureWorkflow('Execution-pinned-data-check.json');
|
||||
|
||||
workflowPage.getters.zoomToFitButton().click();
|
||||
cy.intercept('PATCH', '/rest/workflows/*').as('workflowActivate');
|
||||
workflowPage.getters.activatorSwitch().click();
|
||||
|
||||
cy.wait('@workflowActivate');
|
||||
cy.get('body').type('{esc}');
|
||||
workflowPage.actions.openNode('Webhook');
|
||||
|
||||
cy.contains('label', 'Production URL').should('be.visible').click();
|
||||
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
|
||||
cy.get('.webhook-url').click();
|
||||
ndv.getters.backToCanvas().click();
|
||||
|
||||
cy.readClipboard().then((url) => {
|
||||
cy.request({
|
||||
method: 'GET',
|
||||
url,
|
||||
}).then((resp) => {
|
||||
expect(resp.status).to.eq(200);
|
||||
});
|
||||
});
|
||||
|
||||
cy.intercept('GET', '/rest/executions/*').as('getExecution');
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
|
||||
cy.wait('@getExecution');
|
||||
executionsTab.getters
|
||||
.workflowExecutionPreviewIframe()
|
||||
.should('be.visible')
|
||||
.its('0.contentDocument.body')
|
||||
.should('not.be.empty')
|
||||
|
||||
.then(cy.wrap)
|
||||
.find('.connection-run-items-label')
|
||||
.filter(':contains("5 items")')
|
||||
.should('have.length', 2);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
|
||||
|
||||
const workflowPage = new WorkflowPageClass();
|
||||
const executionsTab = new WorkflowExecutionsTab();
|
||||
|
||||
describe('ADO-2106 connections should be colored correctly for pinned data in executions preview', () => {
|
||||
beforeEach(() => {
|
||||
workflowPage.actions.visit();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cy.createFixtureWorkflow('Webhook_set_pinned.json');
|
||||
workflowPage.actions.deselectAll();
|
||||
workflowPage.getters.zoomToFitButton().click();
|
||||
|
||||
workflowPage.getters.getConnectionBetweenNodes('Webhook', 'Set').should('have.class', 'pinned');
|
||||
});
|
||||
|
||||
it('should color connections for pinned data nodes for manual executions', () => {
|
||||
workflowPage.actions.executeWorkflow();
|
||||
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
|
||||
executionsTab.getters.successfulExecutionListItems().should('have.length', 1);
|
||||
|
||||
executionsTab.getters
|
||||
.workflowExecutionPreviewIframe()
|
||||
.should('be.visible')
|
||||
.its('0.contentDocument.body')
|
||||
.should('not.be.empty')
|
||||
|
||||
.then(cy.wrap)
|
||||
.find('.jtk-connector[data-source-node="Webhook"][data-target-node="Set"]')
|
||||
.should('have.class', 'success')
|
||||
.should('have.class', 'has-run')
|
||||
.should('have.class', 'pinned');
|
||||
});
|
||||
});
|
|
@ -1,135 +0,0 @@
|
|||
import { WorkflowPage, NDV } from '../pages';
|
||||
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
||||
describe('ADO-2111 expressions should support pinned data', () => {
|
||||
beforeEach(() => {
|
||||
workflowPage.actions.visit();
|
||||
});
|
||||
|
||||
it('supports pinned data in expressions unexecuted and executed parent nodes', () => {
|
||||
cy.createFixtureWorkflow('Test_workflow_pinned_data_in_expressions.json', 'Expressions');
|
||||
|
||||
// test previous node unexecuted
|
||||
workflowPage.actions.openNode('NotPinnedWithExpressions');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(0)
|
||||
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(1)
|
||||
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
|
||||
|
||||
// test can resolve correctly based on item
|
||||
ndv.actions.switchInputMode('Table');
|
||||
|
||||
ndv.getters.inputTableRow(2).realHover();
|
||||
cy.wait(50);
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(0)
|
||||
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(1)
|
||||
.should('contain.text', '0,1\nJoan\n\nJoan\n\nJoan\n\nJoan\nJoan');
|
||||
|
||||
// test previous node executed
|
||||
ndv.actions.execute();
|
||||
ndv.getters.inputTableRow(1).realHover();
|
||||
cy.wait(50);
|
||||
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(0)
|
||||
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
|
||||
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(1)
|
||||
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
|
||||
|
||||
ndv.getters.inputTableRow(2).realHover();
|
||||
cy.wait(50);
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(0)
|
||||
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(1)
|
||||
.should('contain.text', '0,1\nJoan\n\nJoan\n\nJoan\n\nJoan\nJoan');
|
||||
|
||||
// check it resolved correctly on the backend
|
||||
ndv.getters
|
||||
.outputTbodyCell(1, 0)
|
||||
.should('contain.text', 'Joe\\nJoe\\nJoan\\nJoan\\nJoe\\nJoan\\n\\nJoe\\nJoan\\n\\nJoe');
|
||||
|
||||
ndv.getters
|
||||
.outputTbodyCell(2, 0)
|
||||
.should('contain.text', 'Joe\\nJoe\\nJoan\\nJoan\\nJoe\\nJoan\\n\\nJoe\\nJoan\\n\\nJoe');
|
||||
|
||||
ndv.getters
|
||||
.outputTbodyCell(1, 1)
|
||||
.should('contain.text', '0,0\\nJoe\\n\\nJoe\\n\\nJoe\\n\\nJoe\\nJoe');
|
||||
|
||||
ndv.getters
|
||||
.outputTbodyCell(2, 1)
|
||||
.should('contain.text', '0,1\\nJoan\\n\\nJoan\\n\\nJoan\\n\\nJoan\\nJoan');
|
||||
});
|
||||
|
||||
it('resets expressions after node is unpinned', () => {
|
||||
cy.createFixtureWorkflow('Test_workflow_pinned_data_in_expressions.json', 'Expressions');
|
||||
|
||||
// test previous node unexecuted
|
||||
workflowPage.actions.openNode('NotPinnedWithExpressions');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(0)
|
||||
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(1)
|
||||
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
|
||||
|
||||
ndv.actions.close();
|
||||
|
||||
// unpin pinned node
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('PinnedSet')
|
||||
.eq(0)
|
||||
.find('.node-pin-data-icon')
|
||||
.should('exist');
|
||||
workflowPage.getters.canvasNodeByName('PinnedSet').eq(0).click();
|
||||
workflowPage.actions.hitPinNodeShortcut();
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('PinnedSet')
|
||||
.eq(0)
|
||||
.find('.node-pin-data-icon')
|
||||
.should('not.exist');
|
||||
|
||||
workflowPage.actions.openNode('NotPinnedWithExpressions');
|
||||
ndv.getters.nodeParameters().find('parameter-expression-preview-value').should('not.exist');
|
||||
|
||||
ndv.getters.parameterInput('value').eq(0).click();
|
||||
ndv.getters
|
||||
.inlineExpressionEditorOutput()
|
||||
.should(
|
||||
'have.text',
|
||||
'[Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute previous nodes for preview][Execute previous nodes for preview][undefined]',
|
||||
);
|
||||
|
||||
// close open expression
|
||||
ndv.getters.inputLabel().eq(0).click();
|
||||
|
||||
ndv.getters.parameterInput('value').eq(1).click();
|
||||
ndv.getters
|
||||
.inlineExpressionEditorOutput()
|
||||
.should(
|
||||
'have.text',
|
||||
'0,0[Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute previous nodes for preview][Execute previous nodes for preview][Execute previous nodes for preview]',
|
||||
);
|
||||
});
|
||||
});
|
|
@ -65,8 +65,11 @@ describe('Variables', () => {
|
|||
const editingRow = variablesPage.getters.variablesEditableRows().eq(0);
|
||||
variablesPage.actions.setRowValue(editingRow, 'key', key);
|
||||
variablesPage.actions.setRowValue(editingRow, 'value', value);
|
||||
editingRow.should('contain', 'This field may contain only letters');
|
||||
variablesPage.getters.editableRowSaveButton(editingRow).should('be.disabled');
|
||||
variablesPage.actions.saveRowEditing(editingRow);
|
||||
variablesPage.getters
|
||||
.variablesEditableRows()
|
||||
.eq(0)
|
||||
.should('contain', 'This field may contain only letters');
|
||||
variablesPage.actions.cancelRowEditing(editingRow);
|
||||
|
||||
variablesPage.getters.variablesRows().should('have.length', 3);
|
||||
|
|
|
@ -118,6 +118,15 @@ describe('NDV', () => {
|
|||
ndv.actions.switchInputMode('Table');
|
||||
ndv.actions.switchOutputMode('Table');
|
||||
|
||||
// Start from linked state
|
||||
ndv.getters.outputLinkRun().then(($el) => {
|
||||
const classList = Array.from($el[0].classList);
|
||||
if (!classList.includes('linked')) {
|
||||
ndv.actions.toggleOutputRunLinking();
|
||||
ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip
|
||||
}
|
||||
});
|
||||
|
||||
ndv.getters
|
||||
.inputRunSelector()
|
||||
.should('exist')
|
||||
|
@ -243,38 +252,38 @@ describe('NDV', () => {
|
|||
// biome-ignore format:
|
||||
const PINNED_DATA = [
|
||||
{
|
||||
"id": "abc",
|
||||
"historyId": "def",
|
||||
"messages": [
|
||||
id: 'abc',
|
||||
historyId: 'def',
|
||||
messages: [
|
||||
{
|
||||
"id": "abc"
|
||||
}
|
||||
]
|
||||
id: 'abc',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "abc",
|
||||
"historyId": "def",
|
||||
"messages": [
|
||||
id: 'abc',
|
||||
historyId: 'def',
|
||||
messages: [
|
||||
{
|
||||
"id": "abc"
|
||||
id: 'abc',
|
||||
},
|
||||
{
|
||||
"id": "abc"
|
||||
id: 'abc',
|
||||
},
|
||||
{
|
||||
"id": "abc"
|
||||
}
|
||||
]
|
||||
id: 'abc',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "abc",
|
||||
"historyId": "def",
|
||||
"messages": [
|
||||
id: 'abc',
|
||||
historyId: 'def',
|
||||
messages: [
|
||||
{
|
||||
"id": "abc"
|
||||
}
|
||||
]
|
||||
}
|
||||
id: 'abc',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
workflowPage.actions.openNode('Get thread details1');
|
||||
ndv.actions.pastePinnedData(PINNED_DATA);
|
||||
|
|
|
@ -3,24 +3,6 @@ import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
|||
|
||||
const workflowPage = new WorkflowPageClass();
|
||||
|
||||
function checkStickiesStyle(
|
||||
top: number,
|
||||
left: number,
|
||||
height: number,
|
||||
width: number,
|
||||
zIndex?: number,
|
||||
) {
|
||||
workflowPage.getters.stickies().should(($el) => {
|
||||
expect($el).to.have.css('top', `${top}px`);
|
||||
expect($el).to.have.css('left', `${left}px`);
|
||||
expect($el).to.have.css('height', `${height}px`);
|
||||
expect($el).to.have.css('width', `${width}px`);
|
||||
if (zIndex) {
|
||||
expect($el).to.have.css('z-index', `${zIndex}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('Canvas Actions', () => {
|
||||
beforeEach(() => {
|
||||
workflowPage.actions.visit();
|
||||
|
@ -51,191 +33,8 @@ describe('Canvas Actions', () => {
|
|||
.contains('Guide')
|
||||
.should('have.attr', 'href');
|
||||
});
|
||||
|
||||
it('drags sticky around to top left corner', () => {
|
||||
// used to caliberate move sticky function
|
||||
addDefaultSticky();
|
||||
moveSticky({ top: 0, left: 0 });
|
||||
});
|
||||
|
||||
it('drags sticky around and position/size are saved correctly', () => {
|
||||
addDefaultSticky();
|
||||
moveSticky({ top: 500, left: 500 });
|
||||
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
cy.wait('@createWorkflow');
|
||||
|
||||
cy.reload();
|
||||
cy.waitForLoad();
|
||||
|
||||
stickyShouldBePositionedCorrectly({ top: 500, left: 500 });
|
||||
});
|
||||
|
||||
it('deletes sticky', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
workflowPage.getters.stickies().should('have.length', 1);
|
||||
|
||||
workflowPage.actions.deleteSticky();
|
||||
|
||||
workflowPage.getters.stickies().should('have.length', 0);
|
||||
});
|
||||
|
||||
it('edits sticky and updates content as markdown', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.should('have.text', 'I’m a note\nDouble click to edit me. Guide\n');
|
||||
|
||||
workflowPage.getters.stickies().dblclick();
|
||||
workflowPage.actions.editSticky('# hello world \n ## text text');
|
||||
workflowPage.getters.stickies().find('h1').should('have.text', 'hello world');
|
||||
workflowPage.getters.stickies().find('h2').should('have.text', 'text text');
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the right edge', () => {
|
||||
addDefaultSticky();
|
||||
|
||||
moveSticky({ top: 200, left: 200 });
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="right"]', [100, 100]);
|
||||
checkStickiesStyle(100, 20, 160, 346);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="right"]', [-50, -50]);
|
||||
checkStickiesStyle(100, 20, 160, 302);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the left edge', () => {
|
||||
addDefaultSticky();
|
||||
|
||||
moveSticky({ left: 600, top: 200 });
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="left"]', [100, 100]);
|
||||
checkStickiesStyle(100, 510, 160, 150);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="left"]', [-50, -50]);
|
||||
checkStickiesStyle(100, 466, 160, 194);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the top edge', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
cy.drag('[data-test-id="sticky"]', [100, 100]); // move away from canvas button
|
||||
checkStickiesStyle(300, 620, 160, 240);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="top"]', [100, 100]);
|
||||
checkStickiesStyle(380, 620, 80, 240);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="top"]', [-50, -50]);
|
||||
checkStickiesStyle(324, 620, 136, 240);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the bottom edge', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
cy.drag('[data-test-id="sticky"]', [100, 100]); // move away from canvas button
|
||||
checkStickiesStyle(300, 620, 160, 240);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="bottom"]', [100, 100]);
|
||||
checkStickiesStyle(300, 620, 254, 240);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="bottom"]', [-50, -50]);
|
||||
checkStickiesStyle(300, 620, 198, 240);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the bottom right edge', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
cy.drag('[data-test-id="sticky"]', [-100, -100]); // move away from canvas button
|
||||
checkStickiesStyle(100, 420, 160, 240);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="bottomRight"]', [100, 100]);
|
||||
checkStickiesStyle(100, 420, 254, 346);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="bottomRight"]', [-50, -50]);
|
||||
checkStickiesStyle(100, 420, 198, 302);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the top right edge', () => {
|
||||
addDefaultSticky();
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topRight"]', [100, 100]);
|
||||
checkStickiesStyle(360, 400, 80, 346);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topRight"]', [-50, -50]);
|
||||
checkStickiesStyle(304, 400, 136, 302);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the top left edge, and reach min height/width', () => {
|
||||
addDefaultSticky();
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [100, 100]);
|
||||
checkStickiesStyle(360, 490, 80, 150);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-150, -150]);
|
||||
checkStickiesStyle(204, 346, 236, 294);
|
||||
});
|
||||
|
||||
it('sets sticky behind node', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
|
||||
addDefaultSticky();
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-150, -150]);
|
||||
checkStickiesStyle(124, 256, 316, 384, -121);
|
||||
|
||||
workflowPage.getters
|
||||
.canvasNodes()
|
||||
.eq(0)
|
||||
.should(($el) => {
|
||||
expect($el).to.have.css('z-index', 'auto');
|
||||
});
|
||||
|
||||
workflowPage.actions.addSticky();
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.eq(0)
|
||||
.should(($el) => {
|
||||
expect($el).to.have.css('z-index', '-121');
|
||||
});
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.eq(1)
|
||||
.should(($el) => {
|
||||
expect($el).to.have.css('z-index', '-38');
|
||||
});
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-200, -200], { index: 1 });
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.eq(0)
|
||||
.should(($el) => {
|
||||
expect($el).to.have.css('z-index', '-121');
|
||||
});
|
||||
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.eq(1)
|
||||
.should(($el) => {
|
||||
expect($el).to.have.css('z-index', '-158');
|
||||
});
|
||||
});
|
||||
|
||||
it('Empty sticky should not error when activating workflow', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
|
||||
workflowPage.getters.stickies().should('have.length', 1);
|
||||
|
||||
workflowPage.getters.stickies().dblclick();
|
||||
|
||||
workflowPage.actions.clearSticky();
|
||||
|
||||
workflowPage.actions.addNodeToCanvas('Schedule Trigger');
|
||||
|
||||
workflowPage.actions.activateWorkflow();
|
||||
});
|
||||
});
|
||||
|
||||
type Position = {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
function shouldHaveOneSticky() {
|
||||
workflowPage.getters.stickies().should('have.length', 1);
|
||||
}
|
||||
|
@ -263,17 +62,3 @@ function addDefaultSticky() {
|
|||
shouldHaveDefaultSize();
|
||||
shouldBeInDefaultLocation();
|
||||
}
|
||||
|
||||
function stickyShouldBePositionedCorrectly(position: Position) {
|
||||
const yOffset = -100;
|
||||
const xOffset = -180;
|
||||
workflowPage.getters.stickies().should(($el) => {
|
||||
expect($el).to.have.css('top', `${yOffset + position.top}px`);
|
||||
expect($el).to.have.css('left', `${xOffset + position.left}px`);
|
||||
});
|
||||
}
|
||||
|
||||
function moveSticky(target: Position) {
|
||||
cy.drag('[data-test-id="sticky"]', [target.left, target.top], { abs: true });
|
||||
stickyShouldBePositionedCorrectly(target);
|
||||
}
|
||||
|
|
|
@ -1,86 +1,7 @@
|
|||
import { getWorkflowHistoryCloseButton } from '../composables/workflow';
|
||||
import {
|
||||
CODE_NODE_NAME,
|
||||
EDIT_FIELDS_SET_NODE_NAME,
|
||||
IF_NODE_NAME,
|
||||
SCHEDULE_TRIGGER_NODE_NAME,
|
||||
} from '../constants';
|
||||
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
|
||||
import { WorkflowPage as WorkflowPageClass } from '../pages';
|
||||
|
||||
const workflowPage = new WorkflowPageClass();
|
||||
const executionsTab = new WorkflowExecutionsTab();
|
||||
|
||||
const createNewWorkflowAndActivate = () => {
|
||||
workflowPage.actions.visit();
|
||||
workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
workflowPage.actions.activateWorkflow();
|
||||
cy.get('.el-notification .el-notification--error').should('not.exist');
|
||||
};
|
||||
|
||||
const editWorkflowAndDeactivate = () => {
|
||||
workflowPage.getters.canvasNodePlusEndpointByName(SCHEDULE_TRIGGER_NODE_NAME).click();
|
||||
workflowPage.getters.nodeCreatorSearchBar().should('be.visible');
|
||||
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false);
|
||||
cy.get('.jtk-connector').should('have.length', 1);
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
workflowPage.getters.activatorSwitch().click();
|
||||
workflowPage.actions.zoomToFit();
|
||||
cy.get('.el-notification .el-notification--error').should('not.exist');
|
||||
};
|
||||
|
||||
const editWorkflowMoreAndActivate = () => {
|
||||
cy.drag(workflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME), [200, 200], {
|
||||
realMouse: true,
|
||||
});
|
||||
workflowPage.getters.nodeCreatorSearchBar().should('be.visible');
|
||||
|
||||
workflowPage.actions.addNodeToCanvas(CODE_NODE_NAME, false);
|
||||
workflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
|
||||
cy.get('.jtk-connector').should('have.length', 2);
|
||||
workflowPage.actions.zoomToFit();
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
|
||||
workflowPage.actions.addNodeToCanvas(IF_NODE_NAME);
|
||||
workflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
|
||||
cy.get('.jtk-connector').should('have.length', 2);
|
||||
|
||||
const position = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
workflowPage.getters
|
||||
.canvasNodeByName(IF_NODE_NAME)
|
||||
.click()
|
||||
.then(($element) => {
|
||||
position.top = $element.position().top;
|
||||
position.left = $element.position().left;
|
||||
});
|
||||
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 200], { clickToFinish: true });
|
||||
workflowPage.getters
|
||||
.canvasNodes()
|
||||
.last()
|
||||
.then(($element) => {
|
||||
const finalPosition = {
|
||||
top: $element.position().top,
|
||||
left: $element.position().left,
|
||||
};
|
||||
|
||||
expect(finalPosition.top).to.be.greaterThan(position.top);
|
||||
expect(finalPosition.left).to.be.greaterThan(position.left);
|
||||
});
|
||||
|
||||
cy.draganddrop(
|
||||
workflowPage.getters.getEndpointSelector('output', CODE_NODE_NAME),
|
||||
workflowPage.getters.getEndpointSelector('input', IF_NODE_NAME),
|
||||
);
|
||||
cy.get('.jtk-connector').should('have.length', 3);
|
||||
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
workflowPage.getters.activatorSwitch().click();
|
||||
cy.get('.el-notification .el-notification--error').should('not.exist');
|
||||
};
|
||||
|
||||
const switchBetweenEditorAndHistory = () => {
|
||||
workflowPage.getters.workflowHistoryButton().click();
|
||||
|
@ -116,62 +37,6 @@ const zoomInAndCheckNodes = () => {
|
|||
workflowPage.getters.canvasNodes().last().should('not.be.visible');
|
||||
};
|
||||
|
||||
describe('Editor actions should work', () => {
|
||||
beforeEach(() => {
|
||||
cy.enableFeature('debugInEditor');
|
||||
cy.enableFeature('workflowHistory');
|
||||
cy.signinAsOwner();
|
||||
createNewWorkflowAndActivate();
|
||||
});
|
||||
|
||||
it('after switching between Editor and Executions', () => {
|
||||
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
|
||||
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
cy.wait(['@getExecutions']);
|
||||
cy.wait(500);
|
||||
executionsTab.actions.switchToEditorTab();
|
||||
editWorkflowAndDeactivate();
|
||||
editWorkflowMoreAndActivate();
|
||||
});
|
||||
|
||||
it('after switching between Editor and Debug', () => {
|
||||
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
|
||||
cy.intercept('GET', '/rest/executions/*').as('getExecution');
|
||||
cy.intercept('POST', '/rest/workflows/**/run?**').as('postWorkflowRun');
|
||||
|
||||
editWorkflowAndDeactivate();
|
||||
workflowPage.actions.executeWorkflow();
|
||||
cy.wait(['@postWorkflowRun']);
|
||||
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
cy.wait(['@getExecutions']);
|
||||
|
||||
executionsTab.getters.executionListItems().should('have.length', 1).first().click();
|
||||
cy.wait(['@getExecution']);
|
||||
|
||||
executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click();
|
||||
editWorkflowMoreAndActivate();
|
||||
});
|
||||
|
||||
it('after switching between Editor and Workflow history', () => {
|
||||
cy.intercept('GET', '/rest/workflow-history/workflow/*/version/*').as('getVersion');
|
||||
cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory');
|
||||
|
||||
editWorkflowAndDeactivate();
|
||||
workflowPage.getters.workflowHistoryButton().click();
|
||||
cy.wait(['@getHistory']);
|
||||
cy.wait(['@getVersion']);
|
||||
|
||||
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
|
||||
getWorkflowHistoryCloseButton().click();
|
||||
cy.wait(['@workflowGet']);
|
||||
cy.wait(1000);
|
||||
|
||||
editWorkflowMoreAndActivate();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor zoom should work after route changes', () => {
|
||||
beforeEach(() => {
|
||||
cy.enableFeature('debugInEditor');
|
||||
|
|
|
@ -38,8 +38,6 @@ import {
|
|||
addToolNodeToParent,
|
||||
clickExecuteWorkflowButton,
|
||||
clickManualChatButton,
|
||||
disableNode,
|
||||
getExecuteWorkflowButton,
|
||||
navigateToNewWorkflowPage,
|
||||
getNodes,
|
||||
openNode,
|
||||
|
@ -73,27 +71,6 @@ describe('Langchain Integration', () => {
|
|||
getManualChatModal().should('not.exist');
|
||||
});
|
||||
|
||||
it('should disable test workflow button', () => {
|
||||
addNodeToCanvas('Schedule Trigger', true);
|
||||
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true);
|
||||
|
||||
clickGetBackToCanvas();
|
||||
|
||||
addNodeToCanvas(AGENT_NODE_NAME, true, true);
|
||||
clickGetBackToCanvas();
|
||||
|
||||
addLanguageModelNodeToParent(
|
||||
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
|
||||
AGENT_NODE_NAME,
|
||||
true,
|
||||
);
|
||||
clickGetBackToCanvas();
|
||||
|
||||
disableNode('Schedule Trigger');
|
||||
|
||||
getExecuteWorkflowButton().should('be.disabled');
|
||||
});
|
||||
|
||||
it('should add nodes to all Agent node input types', () => {
|
||||
addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true);
|
||||
addNodeToCanvas(AGENT_NODE_NAME, true, true);
|
||||
|
@ -368,58 +345,6 @@ describe('Langchain Integration', () => {
|
|||
getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist');
|
||||
getNodes().should('have.length', 3);
|
||||
});
|
||||
it('should render runItems for sub-nodes and allow switching between them', () => {
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
||||
cy.visit(workflowPage.url);
|
||||
cy.createFixtureWorkflow('In_memory_vector_store_fake_embeddings.json');
|
||||
workflowPage.actions.zoomToFit();
|
||||
|
||||
workflowPage.actions.executeNode('Populate VS');
|
||||
cy.get('[data-label="25 items"]').should('exist');
|
||||
|
||||
const assertInputOutputText = (text: string, assertion: 'exist' | 'not.exist') => {
|
||||
ndv.getters.outputPanel().contains(text).should(assertion);
|
||||
ndv.getters.inputPanel().contains(text).should(assertion);
|
||||
};
|
||||
|
||||
workflowPage.actions.openNode('Character Text Splitter');
|
||||
ndv.getters.outputRunSelector().should('exist');
|
||||
ndv.getters.inputRunSelector().should('exist');
|
||||
ndv.getters.inputRunSelector().find('input').should('include.value', '3 of 3');
|
||||
ndv.getters.outputRunSelector().find('input').should('include.value', '3 of 3');
|
||||
assertInputOutputText('Kyiv', 'exist');
|
||||
assertInputOutputText('Berlin', 'not.exist');
|
||||
assertInputOutputText('Prague', 'not.exist');
|
||||
|
||||
ndv.actions.changeOutputRunSelector('2 of 3');
|
||||
assertInputOutputText('Berlin', 'exist');
|
||||
assertInputOutputText('Kyiv', 'not.exist');
|
||||
assertInputOutputText('Prague', 'not.exist');
|
||||
|
||||
ndv.actions.changeOutputRunSelector('1 of 3');
|
||||
assertInputOutputText('Prague', 'exist');
|
||||
assertInputOutputText('Berlin', 'not.exist');
|
||||
assertInputOutputText('Kyiv', 'not.exist');
|
||||
|
||||
ndv.actions.toggleInputRunLinking();
|
||||
ndv.actions.changeOutputRunSelector('2 of 3');
|
||||
ndv.getters.inputRunSelector().find('input').should('include.value', '1 of 3');
|
||||
ndv.getters.outputRunSelector().find('input').should('include.value', '2 of 3');
|
||||
ndv.getters.inputPanel().contains('Prague').should('exist');
|
||||
ndv.getters.inputPanel().contains('Berlin').should('not.exist');
|
||||
|
||||
ndv.getters.outputPanel().contains('Berlin').should('exist');
|
||||
ndv.getters.outputPanel().contains('Prague').should('not.exist');
|
||||
|
||||
ndv.actions.toggleInputRunLinking();
|
||||
ndv.getters.inputRunSelector().find('input').should('include.value', '1 of 3');
|
||||
ndv.getters.outputRunSelector().find('input').should('include.value', '1 of 3');
|
||||
assertInputOutputText('Prague', 'exist');
|
||||
assertInputOutputText('Berlin', 'not.exist');
|
||||
assertInputOutputText('Kyiv', 'not.exist');
|
||||
});
|
||||
|
||||
it('should show tool info notice if no existing tools were used during execution', () => {
|
||||
addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
|
||||
|
@ -518,4 +443,29 @@ describe('Langchain Integration', () => {
|
|||
|
||||
getRunDataInfoCallout().should('not.exist');
|
||||
});
|
||||
|
||||
it('should execute up to Node 1 when using partial execution', () => {
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
||||
cy.visit(workflowPage.url);
|
||||
cy.createFixtureWorkflow('Test_workflow_chat_partial_execution.json');
|
||||
workflowPage.actions.zoomToFit();
|
||||
|
||||
getManualChatModal().should('not.exist');
|
||||
openNode('Node 1');
|
||||
ndv.actions.execute();
|
||||
|
||||
getManualChatModal().should('exist');
|
||||
sendManualChatMessage('Test');
|
||||
|
||||
getManualChatMessages().should('contain', 'this_my_field_1');
|
||||
cy.getByTestId('refresh-session-button').click();
|
||||
cy.get('button').contains('Reset').click();
|
||||
getManualChatMessages().should('not.exist');
|
||||
|
||||
sendManualChatMessage('Another test');
|
||||
getManualChatMessages().should('contain', 'this_my_field_3');
|
||||
getManualChatMessages().should('contain', 'this_my_field_4');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { setCredentialValues } from '../composables/modals/credential-modal';
|
||||
import { clickCreateNewCredential, selectResourceLocatorItem } from '../composables/ndv';
|
||||
import * as projects from '../composables/projects';
|
||||
import {
|
||||
INSTANCE_ADMIN,
|
||||
|
@ -11,18 +13,16 @@ import {
|
|||
WorkflowPage,
|
||||
CredentialsModal,
|
||||
CredentialsPage,
|
||||
WorkflowExecutionsTab,
|
||||
NDV,
|
||||
MainSidebar,
|
||||
} from '../pages';
|
||||
import { clearNotifications, successToast } from '../pages/notifications';
|
||||
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
|
||||
const workflowsPage = new WorkflowsPage();
|
||||
const workflowPage = new WorkflowPage();
|
||||
const credentialsPage = new CredentialsPage();
|
||||
const credentialsModal = new CredentialsModal();
|
||||
const executionsTab = new WorkflowExecutionsTab();
|
||||
const ndv = new NDV();
|
||||
const mainSidebar = new MainSidebar();
|
||||
|
||||
|
@ -36,207 +36,6 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
cy.changeQuota('maxTeamProjects', -1);
|
||||
});
|
||||
|
||||
it('should handle workflows and credentials and menu items', () => {
|
||||
cy.signinAsAdmin();
|
||||
cy.visit(workflowsPage.url);
|
||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
|
||||
cy.intercept('POST', '/rest/workflows').as('workflowSave');
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
|
||||
cy.wait('@workflowSave').then((interception) => {
|
||||
expect(interception.request.body).not.to.have.property('projectId');
|
||||
});
|
||||
|
||||
projects.getHomeButton().click();
|
||||
projects.getProjectTabs().should('have.length', 3);
|
||||
|
||||
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('My awesome Notion account');
|
||||
|
||||
cy.intercept('POST', '/rest/credentials').as('credentialSave');
|
||||
credentialsModal.actions.save();
|
||||
cy.wait('@credentialSave').then((interception) => {
|
||||
expect(interception.request.body).not.to.have.property('projectId');
|
||||
});
|
||||
|
||||
credentialsModal.actions.close();
|
||||
credentialsPage.getters.credentialCards().should('have.length', 1);
|
||||
credentialsPage.getters
|
||||
.credentialCards()
|
||||
.first()
|
||||
.find('.n8n-node-icon img')
|
||||
.should('be.visible');
|
||||
|
||||
projects.getProjectTabWorkflows().click();
|
||||
workflowsPage.getters.workflowCards().should('have.length', 1);
|
||||
|
||||
projects.getMenuItems().should('not.have.length');
|
||||
|
||||
cy.intercept('POST', '/rest/projects').as('projectCreate');
|
||||
projects.getAddProjectButton().click();
|
||||
cy.wait('@projectCreate');
|
||||
projects.getMenuItems().should('have.length', 1);
|
||||
projects.getProjectTabs().should('have.length', 3);
|
||||
|
||||
cy.get('input[name="name"]').type('Development');
|
||||
projects.addProjectMember(INSTANCE_MEMBERS[0].email);
|
||||
|
||||
cy.intercept('PATCH', '/rest/projects/*').as('projectSettingsSave');
|
||||
projects.getProjectSettingsSaveButton().click();
|
||||
cy.wait('@projectSettingsSave').then((interception) => {
|
||||
expect(interception.request.body).to.have.property('name').and.to.equal('Development');
|
||||
expect(interception.request.body).to.have.property('relations').to.have.lengthOf(2);
|
||||
});
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||
projects.getProjectTabs().should('have.length', 4);
|
||||
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
|
||||
cy.intercept('POST', '/rest/workflows').as('workflowSave');
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
|
||||
cy.wait('@workflowSave').then((interception) => {
|
||||
expect(interception.request.body).to.have.property('projectId');
|
||||
});
|
||||
|
||||
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('My awesome Notion account');
|
||||
|
||||
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();
|
||||
|
||||
projects.getAddProjectButton().click();
|
||||
projects.getMenuItems().should('have.length', 2);
|
||||
|
||||
let projectId: string;
|
||||
projects.getMenuItems().first().click();
|
||||
cy.intercept('GET', '/rest/credentials*').as('credentialsList');
|
||||
projects.getProjectTabCredentials().click();
|
||||
cy.wait('@credentialsList').then((interception) => {
|
||||
const url = new URL(interception.request.url);
|
||||
const queryParams = new URLSearchParams(url.search);
|
||||
const filter = queryParams.get('filter');
|
||||
expect(filter).to.be.a('string').and.to.contain('projectId');
|
||||
|
||||
if (filter) {
|
||||
projectId = JSON.parse(filter).projectId;
|
||||
}
|
||||
});
|
||||
|
||||
projects.getMenuItems().last().click();
|
||||
cy.intercept('GET', '/rest/credentials*').as('credentialsListProjectId');
|
||||
projects.getProjectTabCredentials().click();
|
||||
cy.wait('@credentialsListProjectId').then((interception) => {
|
||||
const url = new URL(interception.request.url);
|
||||
const queryParams = new URLSearchParams(url.search);
|
||||
const filter = queryParams.get('filter');
|
||||
expect(filter).to.be.a('string').and.to.contain('projectId');
|
||||
|
||||
if (filter) {
|
||||
expect(JSON.parse(filter).projectId).not.to.equal(projectId);
|
||||
}
|
||||
});
|
||||
|
||||
projects.getHomeButton().click();
|
||||
workflowsPage.getters.workflowCards().should('have.length', 2);
|
||||
|
||||
cy.intercept('GET', '/rest/credentials*').as('credentialsListUnfiltered');
|
||||
projects.getProjectTabCredentials().click();
|
||||
cy.wait('@credentialsListUnfiltered').then((interception) => {
|
||||
expect(interception.request.url).not.to.contain('filter');
|
||||
});
|
||||
|
||||
let menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Overview")[class*=active_]').should('exist');
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
|
||||
|
||||
cy.intercept('GET', '/rest/workflows/*').as('loadWorkflow');
|
||||
workflowsPage.getters.workflowCards().first().findChildByTestId('card-content').click();
|
||||
|
||||
cy.wait('@loadWorkflow');
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
|
||||
|
||||
cy.intercept('GET', '/rest/executions*').as('loadExecutions');
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
|
||||
cy.wait('@loadExecutions');
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
|
||||
|
||||
executionsTab.actions.switchToEditorTab();
|
||||
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
|
||||
|
||||
cy.getByTestId('menu-item').filter(':contains("Variables")').click();
|
||||
cy.getByTestId('unavailable-resources-list').should('be.visible');
|
||||
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Variables")[class*=active_]').should('exist');
|
||||
|
||||
projects.getHomeButton().click();
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Overview")[class*=active_]').should('exist');
|
||||
|
||||
workflowsPage.getters.workflowCards().should('have.length', 2).first().click();
|
||||
|
||||
cy.wait('@loadWorkflow');
|
||||
cy.getByTestId('execute-workflow-button').should('be.visible');
|
||||
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
menuItems.filter(':contains("Overview")[class*=active_]').should('not.exist');
|
||||
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
|
||||
});
|
||||
|
||||
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);
|
||||
|
@ -245,26 +44,6 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects.getMenuItems().should('not.exist');
|
||||
});
|
||||
|
||||
it('should not show viewer role if not licensed', () => {
|
||||
cy.signinAsOwner();
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
projects.getProjectTabSettings().click();
|
||||
|
||||
cy.get(
|
||||
`[data-test-id="user-list-item-${INSTANCE_MEMBERS[0].email}"] [data-test-id="projects-settings-user-role-select"]`,
|
||||
).click();
|
||||
|
||||
cy.get('.el-select-dropdown__item.is-disabled')
|
||||
.should('contain.text', 'Viewer')
|
||||
.get('span:contains("Upgrade")')
|
||||
.filter(':visible')
|
||||
.click();
|
||||
|
||||
getVisibleModalOverlay().should('contain.text', 'Upgrade to unlock additional roles');
|
||||
});
|
||||
|
||||
describe('when starting from scratch', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
|
@ -275,7 +54,11 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
cy.changeQuota('maxTeamProjects', -1);
|
||||
});
|
||||
|
||||
it('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => {
|
||||
/**
|
||||
* @TODO: New Canvas - Fix this test
|
||||
*/
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
it.skip('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => {
|
||||
cy.signinAsOwner();
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
|
@ -754,80 +537,64 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
ndv.getters.credentialInput().find('input').should('be.enabled');
|
||||
});
|
||||
|
||||
it('should handle viewer role', () => {
|
||||
cy.enableFeature('projectRole:viewer');
|
||||
it('should create sub-workflow and credential in the sub-workflow in the same project', () => {
|
||||
cy.signinAsOwner();
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
projects.createProject('Development');
|
||||
projects.addProjectMember(INSTANCE_MEMBERS[0].email, 'Viewer');
|
||||
projects.getProjectSettingsSaveButton().click();
|
||||
|
||||
projects.createProject('Dev');
|
||||
projects.getProjectTabWorkflows().click();
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
projects.createWorkflow('Test_workflow_4_executions_view.json', 'WF with random error');
|
||||
executionsTab.actions.createManualExecutions(2);
|
||||
executionsTab.actions.toggleNodeEnabled('Error');
|
||||
executionsTab.actions.createManualExecutions(2);
|
||||
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
|
||||
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
workflowPage.actions.addNodeToCanvas('Execute Workflow', true, true);
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||
projects.createCredential('Notion API');
|
||||
cy.window().then((win) => {
|
||||
cy.stub(win, 'open').callsFake((url) => {
|
||||
cy.visit(url);
|
||||
});
|
||||
});
|
||||
|
||||
mainSidebar.actions.openUserMenu();
|
||||
cy.getByTestId('user-menu-item-logout').click();
|
||||
selectResourceLocatorItem('workflowId', 0, 'Create a');
|
||||
|
||||
cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email);
|
||||
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
|
||||
cy.getByTestId('form-submit-button').click();
|
||||
cy.get('body').type('{esc}');
|
||||
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
|
||||
clickCreateNewCredential();
|
||||
setCredentialValues({
|
||||
apiKey: 'abc123',
|
||||
});
|
||||
ndv.actions.close();
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
|
||||
projects.getMenuItems().last().click();
|
||||
projects.getProjectTabExecutions().click();
|
||||
cy.getByTestId('global-execution-list-item').first().find('td:last button').click();
|
||||
getVisibleDropdown()
|
||||
.find('li')
|
||||
.filter(':contains("Retry")')
|
||||
.should('have.class', 'is-disabled');
|
||||
getVisibleDropdown()
|
||||
.find('li')
|
||||
.filter(':contains("Delete")')
|
||||
.should('have.class', 'is-disabled');
|
||||
workflowsPage.getters.workflowCards().should('have.length', 2);
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
cy.getByTestId('workflow-card-name').should('be.visible').first().click();
|
||||
workflowPage.getters.nodeViewRoot().should('be.visible');
|
||||
workflowPage.getters.executeWorkflowButton().should('not.exist');
|
||||
workflowPage.getters.nodeCreatorPlusButton().should('not.exist');
|
||||
workflowPage.getters.canvasNodes().should('have.length', 3).last().click();
|
||||
cy.get('body').type('{backspace}');
|
||||
workflowPage.getters.canvasNodes().should('have.length', 3).last().rightclick();
|
||||
getVisibleDropdown()
|
||||
.find('li')
|
||||
.should('be.visible')
|
||||
.filter(
|
||||
':contains("Open"), :contains("Copy"), :contains("Select all"), :contains("Clear selection")',
|
||||
)
|
||||
.should('not.have.class', 'is-disabled');
|
||||
cy.get('body').type('{esc}');
|
||||
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
cy.getByTestId('retry-execution-button')
|
||||
.should('be.visible')
|
||||
.find('.is-disabled')
|
||||
.should('exist');
|
||||
cy.get('button:contains("Debug")').should('be.disabled');
|
||||
cy.get('button[title="Retry execution"]').should('be.disabled');
|
||||
cy.get('button[title="Delete this execution"]').should('be.disabled');
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters.credentialCards().filter(':contains("Notion")').click();
|
||||
cy.getByTestId('node-credentials-config-container')
|
||||
.should('be.visible')
|
||||
.find('input')
|
||||
.should('not.have.length');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -135,7 +135,6 @@ describe('Node Creator', () => {
|
|||
'OpenThesaurus',
|
||||
'Spontit',
|
||||
'Vonage',
|
||||
'Send Email',
|
||||
'Toggl Trigger',
|
||||
];
|
||||
const doubleActionNode = 'OpenWeatherMap';
|
||||
|
|
|
@ -17,7 +17,7 @@ describe('Workflow Selector Parameter', () => {
|
|||
workflowPage.actions.visit();
|
||||
workflowPage.actions.addInitialNodeToCanvas(EXECUTE_WORKFLOW_NODE_NAME, {
|
||||
keepNdvOpen: true,
|
||||
action: 'Call Another Workflow',
|
||||
action: 'Execute A Sub Workflow',
|
||||
});
|
||||
});
|
||||
it('should render sub-workflows list', () => {
|
||||
|
@ -86,6 +86,8 @@ describe('Workflow Selector Parameter', () => {
|
|||
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();
|
||||
|
||||
|
@ -98,10 +100,20 @@ describe('Workflow Selector Parameter', () => {
|
|||
|
||||
getVisiblePopper().findChildByTestId('rlc-item').eq(0).click();
|
||||
|
||||
const SAMPLE_SUBWORKFLOW_TEMPLATE_ID = 'VMiAxXa3lCAizGB5f7dVZQSFfg3FtHkdTKvLuupqBls=';
|
||||
cy.get('@windowOpen').should(
|
||||
'be.calledWith',
|
||||
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_TEMPLATE_ID}?sampleSubWorkflows=0`,
|
||||
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,9 +1,3 @@
|
|||
import {
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink,
|
||||
getExecutionsSidebar,
|
||||
getWorkflowExecutionPreviewIframe,
|
||||
openExecutionPreviewNode,
|
||||
} from '../composables/executions';
|
||||
import {
|
||||
changeOutputRunSelector,
|
||||
getOutputPanelItemsCount,
|
||||
|
@ -103,38 +97,4 @@ describe('Subworkflow debugging', () => {
|
|||
getOutputTbodyCell(1, 2).should('include.text', 'Terry.Dach@hotmail.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('can inspect parent executions', () => {
|
||||
cy.url().then((workflowUrl) => {
|
||||
openNode('Execute Workflow with param');
|
||||
|
||||
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||
|
||||
// ensure workflow executed and waited on output
|
||||
getOutputTableHeaders().should('have.length', 2);
|
||||
getOutputTbodyCell(1, 0).should('have.text', 'world Natalie Moore');
|
||||
|
||||
// cypress cannot handle new tabs so removing it
|
||||
getOutputPanelRelatedExecutionLink().invoke('removeAttr', 'target').click();
|
||||
|
||||
getExecutionsSidebar().should('be.visible');
|
||||
getWorkflowExecutionPreviewIframe().should('be.visible');
|
||||
openExecutionPreviewNode('Execute Workflow Trigger');
|
||||
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
|
||||
'include.text',
|
||||
'View parent execution',
|
||||
);
|
||||
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink()
|
||||
.invoke('removeAttr', 'target')
|
||||
.click({ force: true });
|
||||
|
||||
cy.url().then((currentUrl) => {
|
||||
expect(currentUrl === workflowUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -65,8 +65,9 @@ describe('Sub-workflow creation and typed usage', () => {
|
|||
// **************************
|
||||
// NAVIGATE TO CHILD WORKFLOW
|
||||
// **************************
|
||||
|
||||
openNode('Workflow Input Trigger');
|
||||
// Close NDV before opening the node creator
|
||||
cy.get('body').type('{esc}');
|
||||
openNode('When Executed by Another Workflow');
|
||||
});
|
||||
|
||||
it('works with type-checked values', () => {
|
||||
|
@ -138,11 +139,9 @@ describe('Sub-workflow creation and typed usage', () => {
|
|||
cy.window().then((win) => {
|
||||
cy.stub(win, 'open').callsFake((url) => {
|
||||
cy.visit(url);
|
||||
});
|
||||
});
|
||||
selectResourceLocatorItem('workflowId', 0, 'Create a');
|
||||
|
||||
openNode('Workflow Input Trigger');
|
||||
openNode('When Executed by Another Workflow');
|
||||
|
||||
getParameterInputByName('inputSource').click();
|
||||
|
||||
|
@ -174,6 +173,8 @@ describe('Sub-workflow creation and typed usage', () => {
|
|||
|
||||
clickExecuteNode();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show node issue when no fields are defined in manual mode', () => {
|
||||
getExecuteNodeButton().should('be.disabled');
|
||||
|
@ -181,7 +182,7 @@ describe('Sub-workflow creation and typed usage', () => {
|
|||
// Executing the workflow should show an error toast
|
||||
clickExecuteWorkflowButton();
|
||||
errorToast().should('contain', 'The workflow has issues');
|
||||
openNode('Workflow Input Trigger');
|
||||
openNode('When Executed by Another Workflow');
|
||||
// Add a field to the workflowInputs fixedCollection
|
||||
addItemToFixedCollection('workflowInputs');
|
||||
typeIntoFixedCollectionItem('workflowInputs', 0, 'test');
|
||||
|
|
|
@ -249,6 +249,15 @@ describe('NDV', () => {
|
|||
ndv.actions.switchInputMode('Table');
|
||||
ndv.actions.switchOutputMode('Table');
|
||||
|
||||
// Start from linked state
|
||||
ndv.getters.outputLinkRun().then(($el) => {
|
||||
const classList = Array.from($el[0].classList);
|
||||
if (!classList.includes('linked')) {
|
||||
ndv.actions.toggleOutputRunLinking();
|
||||
ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip
|
||||
}
|
||||
});
|
||||
|
||||
ndv.getters
|
||||
.inputRunSelector()
|
||||
.should('exist')
|
||||
|
|
|
@ -200,7 +200,14 @@ describe('Workflow Actions', () => {
|
|||
WorkflowPage.getters.nodeConnections().should('have.length', 2);
|
||||
// Check if all nodes have names
|
||||
WorkflowPage.getters.canvasNodes().each((node) => {
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
cy.wrap(node).should('have.attr', 'data-name');
|
||||
},
|
||||
() => {
|
||||
cy.wrap(node).should('have.attr', 'data-node-name');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -105,7 +105,7 @@ describe('Expression editor modal', () => {
|
|||
// Run workflow
|
||||
cy.get('body').type('{esc}');
|
||||
ndv.actions.close();
|
||||
WorkflowPage.actions.executeNode('No Operation');
|
||||
WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' });
|
||||
WorkflowPage.actions.openNode('Hacker News');
|
||||
WorkflowPage.actions.openExpressionEditorModal();
|
||||
|
||||
|
|
77
cypress/fixtures/Test_workflow_chat_partial_execution.json
Normal file
77
cypress/fixtures/Test_workflow_chat_partial_execution.json
Normal file
|
@ -0,0 +1,77 @@
|
|||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "535fd3dd-e78f-4ffa-a085-79723fc81b38",
|
||||
"name": "When chat message received",
|
||||
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
320,
|
||||
-380
|
||||
],
|
||||
"webhookId": "4fb58136-3481-494a-a30f-d9e064dac186"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "raw",
|
||||
"jsonOutput": "{\n \"this_my_field_1\": \"value\",\n \"this_my_field_2\": 1\n}\n",
|
||||
"options": {}
|
||||
},
|
||||
"id": "78201ec2-6def-40b7-85e5-97b580d7f642",
|
||||
"name": "Node 1",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
580,
|
||||
-380
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "raw",
|
||||
"jsonOutput": "{\n \"this_my_field_3\": \"value\",\n \"this_my_field_4\": 1\n}\n",
|
||||
"options": {}
|
||||
},
|
||||
"id": "1cfca06d-3ec3-427f-89f7-1ef321e025ff",
|
||||
"name": "Node 2",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
780,
|
||||
-380
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When chat message received": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Node 1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Node 1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Node 2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "178ef8a5109fc76c716d40bcadb720c455319f7b7a3fd5a39e4f336a091f524a"
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@
|
|||
"cypress:install": "cypress install",
|
||||
"test:e2e:ui": "scripts/run-e2e.js ui",
|
||||
"test:e2e:dev": "scripts/run-e2e.js dev",
|
||||
"test:e2e:dev:v2": "scripts/run-e2e.js dev:v2",
|
||||
"test:e2e:dev:v1": "scripts/run-e2e.js dev:v1",
|
||||
"test:e2e:all": "scripts/run-e2e.js all",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
|
|
|
@ -68,7 +68,10 @@ export class VariablesPage extends BasePage {
|
|||
},
|
||||
setRowValue: (row: Chainable<JQuery<HTMLElement>>, field: 'key' | 'value', value: string) => {
|
||||
row.within(() => {
|
||||
cy.getByTestId(`variable-row-${field}-input`).type('{selectAll}{del}').type(value);
|
||||
cy.getByTestId(`variable-row-${field}-input`)
|
||||
.find('input, textarea')
|
||||
.type('{selectAll}{del}')
|
||||
.type(value);
|
||||
});
|
||||
},
|
||||
cancelRowEditing: (row: Chainable<JQuery<HTMLElement>>) => {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { BasePage } from './base';
|
||||
import { NodeCreator } from './features/node-creator';
|
||||
import { clickContextMenuAction, getCanvasPane, openContextMenu } from '../composables/workflow';
|
||||
import { META_KEY } from '../constants';
|
||||
import type { OpenContextMenuOptions } from '../types';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
|
@ -38,15 +39,7 @@ export class WorkflowPage extends BasePage {
|
|||
nodeCreatorSearchBar: () => cy.getByTestId('node-creator-search-bar'),
|
||||
nodeCreatorPlusButton: () => cy.getByTestId('node-creator-plus-button'),
|
||||
canvasPlusButton: () => cy.getByTestId('canvas-plus-button'),
|
||||
canvasNodes: () =>
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.getByTestId('canvas-node'),
|
||||
() =>
|
||||
cy
|
||||
.getByTestId('canvas-node')
|
||||
.not('[data-node-type="n8n-nodes-internal.addNodes"]')
|
||||
.not('[data-node-type="n8n-nodes-base.stickyNote"]'),
|
||||
),
|
||||
canvasNodes: () => cy.getByTestId('canvas-node'),
|
||||
canvasNodeByName: (nodeName: string) =>
|
||||
this.getters.canvasNodes().filter(`:contains(${nodeName})`),
|
||||
nodeIssuesByName: (nodeName: string) =>
|
||||
|
@ -103,14 +96,14 @@ export class WorkflowPage extends BasePage {
|
|||
nodeConnections: () =>
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.get('.jtk-connector'),
|
||||
() => cy.getByTestId('edge-label'),
|
||||
() => cy.getByTestId('edge'),
|
||||
),
|
||||
zoomToFitButton: () => cy.getByTestId('zoom-to-fit'),
|
||||
nodeEndpoints: () => cy.get('.jtk-endpoint-connected'),
|
||||
disabledNodes: () =>
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.get('.node-box.disabled'),
|
||||
() => cy.get('[data-test-id*="node"][class*="disabled"]'),
|
||||
() => cy.get('[data-canvas-node-render-type][class*="disabled"]'),
|
||||
),
|
||||
selectedNodes: () =>
|
||||
cy.ifCanvasVersion(
|
||||
|
@ -189,7 +182,7 @@ export class WorkflowPage extends BasePage {
|
|||
),
|
||||
() =>
|
||||
cy.get(
|
||||
`[data-test-id="edge-label"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
|
||||
`[data-test-id="edge"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
|
||||
),
|
||||
),
|
||||
getConnectionActionsBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
|
||||
|
@ -288,6 +281,8 @@ export class WorkflowPage extends BasePage {
|
|||
nodeTypeName?: string,
|
||||
{ method = 'right-click', anchor = 'center' }: OpenContextMenuOptions = {},
|
||||
) => {
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
const target = nodeTypeName
|
||||
? this.getters.canvasNodeByName(nodeTypeName)
|
||||
: this.getters.nodeViewBackground();
|
||||
|
@ -299,60 +294,64 @@ export class WorkflowPage extends BasePage {
|
|||
target.find('[data-test-id="overflow-node-button"]').click({ force: true });
|
||||
}
|
||||
},
|
||||
() => {
|
||||
openContextMenu(nodeTypeName, { method, anchor });
|
||||
},
|
||||
);
|
||||
},
|
||||
openNode: (nodeTypeName: string) => {
|
||||
this.getters.canvasNodeByName(nodeTypeName).first().dblclick();
|
||||
},
|
||||
duplicateNode: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('duplicate');
|
||||
clickContextMenuAction('duplicate');
|
||||
},
|
||||
deleteNodeFromContextMenu: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('delete');
|
||||
clickContextMenuAction('delete');
|
||||
},
|
||||
executeNode: (nodeTypeName: string, options?: OpenContextMenuOptions) => {
|
||||
this.actions.openContextMenu(nodeTypeName, options);
|
||||
this.actions.contextMenuAction('execute');
|
||||
clickContextMenuAction('execute');
|
||||
},
|
||||
addStickyFromContextMenu: () => {
|
||||
this.actions.openContextMenu();
|
||||
this.actions.contextMenuAction('add_sticky');
|
||||
clickContextMenuAction('add_sticky');
|
||||
},
|
||||
renameNode: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('rename');
|
||||
clickContextMenuAction('rename');
|
||||
},
|
||||
copyNode: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('copy');
|
||||
clickContextMenuAction('copy');
|
||||
},
|
||||
contextMenuAction: (action: string) => {
|
||||
this.getters.contextMenuAction(action).click();
|
||||
},
|
||||
disableNode: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('toggle_activation');
|
||||
clickContextMenuAction('toggle_activation');
|
||||
},
|
||||
pinNode: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('toggle_pin');
|
||||
clickContextMenuAction('toggle_pin');
|
||||
},
|
||||
openNodeFromContextMenu: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName, { method: 'overflow-button' });
|
||||
this.actions.contextMenuAction('open');
|
||||
clickContextMenuAction('open');
|
||||
},
|
||||
selectAllFromContextMenu: () => {
|
||||
this.actions.openContextMenu();
|
||||
this.actions.contextMenuAction('select_all');
|
||||
clickContextMenuAction('select_all');
|
||||
},
|
||||
deselectAll: () => {
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
this.actions.openContextMenu();
|
||||
this.actions.contextMenuAction('deselect_all');
|
||||
clickContextMenuAction('deselect_all');
|
||||
},
|
||||
// rightclick doesn't work with vueFlow canvas
|
||||
() => this.getters.nodeViewBackground().click('topLeft'),
|
||||
() => getCanvasPane().click('topLeft'),
|
||||
);
|
||||
},
|
||||
openExpressionEditorModal: () => {
|
||||
|
@ -431,7 +430,7 @@ export class WorkflowPage extends BasePage {
|
|||
pinchToZoom: (steps: number, mode: 'zoomIn' | 'zoomOut' = 'zoomIn') => {
|
||||
cy.window().then((win) => {
|
||||
// Pinch-to-zoom simulates a 'wheel' event with ctrlKey: true (same as zooming by scrolling)
|
||||
this.getters.nodeView().trigger('wheel', {
|
||||
getCanvasPane().trigger('wheel', {
|
||||
force: true,
|
||||
bubbles: true,
|
||||
ctrlKey: true,
|
||||
|
|
|
@ -45,19 +45,23 @@ switch (scenario) {
|
|||
startCommand: 'start',
|
||||
url: 'http://localhost:5678/favicon.ico',
|
||||
testCommand: 'cypress open',
|
||||
customEnv: {
|
||||
CYPRESS_NODE_VIEW_VERSION: 2,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'dev':
|
||||
case 'dev:v1':
|
||||
runTests({
|
||||
startCommand: 'develop',
|
||||
url: 'http://localhost:8080/favicon.ico',
|
||||
testCommand: 'cypress open',
|
||||
customEnv: {
|
||||
CYPRESS_NODE_VIEW_VERSION: 1,
|
||||
CYPRESS_BASE_URL: 'http://localhost:8080',
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'dev:v2':
|
||||
case 'dev':
|
||||
runTests({
|
||||
startCommand: 'develop',
|
||||
url: 'http://localhost:8080/favicon.ico',
|
||||
|
@ -76,6 +80,9 @@ switch (scenario) {
|
|||
startCommand: 'start',
|
||||
url: 'http://localhost:5678/favicon.ico',
|
||||
testCommand: `cypress run --headless ${specParam}`,
|
||||
customEnv: {
|
||||
CYPRESS_NODE_VIEW_VERSION: 2,
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
|
|
|
@ -77,7 +77,7 @@ Cypress.Commands.add('signin', ({ email, password }) => {
|
|||
|
||||
// @TODO Remove this once the switcher is removed
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('NodeView.migrated', 'true');
|
||||
win.localStorage.setItem('NodeView.migrated.release', 'true');
|
||||
win.localStorage.setItem('NodeView.switcher.discovered.beta', 'true');
|
||||
|
||||
const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION');
|
||||
|
@ -172,6 +172,7 @@ Cypress.Commands.add('drag', (selector, pos, options) => {
|
|||
};
|
||||
if (options?.realMouse) {
|
||||
element.realMouseDown();
|
||||
element.realMouseMove(0, 0);
|
||||
element.realMouseMove(newPosition.x, newPosition.y);
|
||||
element.realMouseUp();
|
||||
} else {
|
||||
|
@ -218,8 +219,15 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector, optio
|
|||
const pageY = coords.top + coords.height / 2;
|
||||
|
||||
if (draggableSelector) {
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
// We can't use realMouseDown here because it hangs headless run
|
||||
cy.get(draggableSelector).trigger('mousedown');
|
||||
},
|
||||
() => {
|
||||
cy.get(draggableSelector).realMouseDown();
|
||||
},
|
||||
);
|
||||
}
|
||||
// We don't chain these commands to make sure cy.get is re-trying correctly
|
||||
cy.get(droppableSelector).realMouseMove(0, 0);
|
||||
|
|
|
@ -38,7 +38,21 @@ beforeEach(() => {
|
|||
data: { status: 'success', message: 'Tested successfully' },
|
||||
}).as('credentialTest');
|
||||
|
||||
cy.intercept('POST', '/rest/license/renew', {});
|
||||
cy.intercept('POST', '/rest/license/renew', {
|
||||
data: {
|
||||
usage: {
|
||||
activeWorkflowTriggers: {
|
||||
limit: -1,
|
||||
value: 0,
|
||||
warningThreshold: 0.8,
|
||||
},
|
||||
},
|
||||
license: {
|
||||
planId: '',
|
||||
planName: 'Community',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cy.intercept({ pathname: '/api/health' }, { status: 'OK' }).as('healthCheck');
|
||||
cy.intercept({ pathname: '/api/versions/*' }, [
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "1.75.0",
|
||||
"version": "1.77.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.15",
|
||||
|
@ -21,7 +21,7 @@
|
|||
"dev:fe": "run-p start \"dev:fe:editor --filter=n8n-design-system\"",
|
||||
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
|
||||
"dev:e2e": "cd cypress && pnpm run test:e2e:dev",
|
||||
"dev:e2e:v2": "cd cypress && pnpm run test:e2e:dev:v2",
|
||||
"dev:e2e:v1": "cd cypress && pnpm run test:e2e:dev:v1",
|
||||
"dev:e2e:server": "run-p start dev:fe:editor",
|
||||
"clean": "turbo run clean --parallel",
|
||||
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
||||
|
@ -58,7 +58,7 @@
|
|||
"jest-mock": "^29.6.2",
|
||||
"jest-mock-extended": "^3.0.4",
|
||||
"lefthook": "^1.7.15",
|
||||
"nock": "^13.3.2",
|
||||
"nock": "^14.0.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"p-limit": "^3.1.0",
|
||||
|
|
9
packages/@n8n/api-types/src/api-keys.ts
Normal file
9
packages/@n8n/api-types/src/api-keys.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export type ApiKey = {
|
||||
id: string;
|
||||
label: string;
|
||||
apiKey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ApiKeyWithRawValue = ApiKey & { rawApiKey: string };
|
|
@ -0,0 +1,40 @@
|
|||
import { CreateOrUpdateApiKeyRequestDto } from '../create-or-update-api-key-request.dto';
|
||||
|
||||
describe('CreateOrUpdateApiKeyRequestDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test('should allow valid label', () => {
|
||||
const result = CreateOrUpdateApiKeyRequestDto.safeParse({
|
||||
label: 'valid label',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty label',
|
||||
label: '',
|
||||
expectedErrorPath: ['label'],
|
||||
},
|
||||
{
|
||||
name: 'label exceeding 50 characters',
|
||||
label: '2mWMfsrvAmneWluS8IbezaIHZOu2mWMfsrvAmneWluS8IbezaIa',
|
||||
expectedErrorPath: ['label'],
|
||||
},
|
||||
{
|
||||
name: 'label with xss injection',
|
||||
label: '<script>alert("xss");new label</script>',
|
||||
expectedErrorPath: ['label'],
|
||||
},
|
||||
])('should fail validation for $name', ({ label, expectedErrorPath }) => {
|
||||
const result = CreateOrUpdateApiKeyRequestDto.safeParse({ label });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
import xss from 'xss';
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
const xssCheck = (value: string) =>
|
||||
value ===
|
||||
xss(value, {
|
||||
whiteList: {},
|
||||
});
|
||||
|
||||
export class CreateOrUpdateApiKeyRequestDto extends Z.class({
|
||||
label: z.string().max(50).min(1).refine(xssCheck),
|
||||
}) {}
|
|
@ -47,3 +47,5 @@ export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.d
|
|||
|
||||
export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto';
|
||||
export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto';
|
||||
|
||||
export { CreateOrUpdateApiKeyRequestDto } from './api-keys/create-or-update-api-key-request.dto';
|
||||
|
|
|
@ -87,6 +87,7 @@ export interface FrontendSettings {
|
|||
};
|
||||
};
|
||||
publicApi: {
|
||||
apiKeysPerUserLimit: number;
|
||||
enabled: boolean;
|
||||
latestVersion: number;
|
||||
path: string;
|
||||
|
|
|
@ -4,6 +4,7 @@ export type * from './push';
|
|||
export type * from './scaling';
|
||||
export type * from './frontend-settings';
|
||||
export type * from './user';
|
||||
export type * from './api-keys';
|
||||
|
||||
export type { Collaborator } from './push/collaboration';
|
||||
export type { SendWorkerStatusMessage } from './push/worker';
|
||||
|
|
|
@ -27,7 +27,7 @@ docker run ghcr.io/n8n-io/n8n-benchmark:latest run \
|
|||
--n8nUserPassword=InstanceOwnerPassword \
|
||||
--vus=5 \
|
||||
--duration=1m \
|
||||
--scenarioFilter SingleWebhook
|
||||
--scenarioFilter=single-webhook
|
||||
```
|
||||
|
||||
### Using custom scenarios with the Docker image
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-benchmark",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"description": "Cli for running benchmark tests for n8n",
|
||||
"main": "dist/index",
|
||||
"scripts": {
|
||||
|
|
|
@ -11,5 +11,6 @@ module.exports = {
|
|||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
'n8n-local-rules/no-plain-errors': 'off',
|
||||
'n8n-local-rules/no-uncaught-json-parse': 'off',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios from 'axios';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { Agent } from 'https';
|
||||
import * as qs from 'querystring';
|
||||
|
||||
|
@ -10,7 +8,7 @@ import type { ClientOAuth2TokenData } from './ClientOAuth2Token';
|
|||
import { ClientOAuth2Token } from './ClientOAuth2Token';
|
||||
import { CodeFlow } from './CodeFlow';
|
||||
import { CredentialsFlow } from './CredentialsFlow';
|
||||
import type { Headers } from './types';
|
||||
import type { Headers, OAuth2AccessTokenErrorResponse } from './types';
|
||||
import { getAuthError } from './utils';
|
||||
|
||||
export interface ClientOAuth2RequestObject {
|
||||
|
@ -38,10 +36,10 @@ export interface ClientOAuth2Options {
|
|||
ignoreSSLIssues?: boolean;
|
||||
}
|
||||
|
||||
class ResponseError extends Error {
|
||||
export class ResponseError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly body: object,
|
||||
readonly body: unknown,
|
||||
readonly code = 'ESTATUS',
|
||||
) {
|
||||
super(`HTTP status ${status}`);
|
||||
|
@ -74,21 +72,12 @@ export class ClientOAuth2 {
|
|||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse response body as JSON, fall back to parsing as a query string.
|
||||
* Request an access token from the OAuth2 server.
|
||||
*
|
||||
* @throws {ResponseError} If the response is an unexpected status code.
|
||||
* @throws {AuthError} If the response is an authentication error.
|
||||
*/
|
||||
private parseResponseBody<T extends object>(body: string): T {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch (e) {
|
||||
return qs.parse(body) as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Using the built-in request method, we'll automatically attempt to parse
|
||||
* the response.
|
||||
*/
|
||||
async request<T extends object>(options: ClientOAuth2RequestObject): Promise<T> {
|
||||
async accessTokenRequest(options: ClientOAuth2RequestObject): Promise<ClientOAuth2TokenData> {
|
||||
let url = options.url;
|
||||
const query = qs.stringify(options.query);
|
||||
|
||||
|
@ -101,7 +90,7 @@ export class ClientOAuth2 {
|
|||
method: options.method,
|
||||
data: qs.stringify(options.body),
|
||||
headers: options.headers,
|
||||
transformResponse: (res) => res,
|
||||
transformResponse: (res: unknown) => res,
|
||||
// Axios rejects the promise by default for all status codes 4xx.
|
||||
// We override this to reject promises only on 5xxs
|
||||
validateStatus: (status) => status < 500,
|
||||
|
@ -113,16 +102,36 @@ export class ClientOAuth2 {
|
|||
|
||||
const response = await axios.request(requestConfig);
|
||||
|
||||
const body = this.parseResponseBody<T>(response.data);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (response.status >= 400) {
|
||||
const body = this.parseResponseBody<OAuth2AccessTokenErrorResponse>(response);
|
||||
const authErr = getAuthError(body);
|
||||
|
||||
if (authErr) throw authErr;
|
||||
else throw new ResponseError(response.status, response.data);
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status >= 399)
|
||||
if (response.status >= 300) {
|
||||
throw new ResponseError(response.status, response.data);
|
||||
}
|
||||
|
||||
return body;
|
||||
return this.parseResponseBody<ClientOAuth2TokenData>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse response body based on the content type.
|
||||
*/
|
||||
private parseResponseBody<T extends object>(response: AxiosResponse<unknown>): T {
|
||||
const contentType = (response.headers['content-type'] as string) ?? '';
|
||||
const body = response.data as string;
|
||||
|
||||
if (contentType.startsWith('application/json')) {
|
||||
return JSON.parse(body) as T;
|
||||
}
|
||||
|
||||
if (contentType.startsWith('application/x-www-form-urlencoded')) {
|
||||
return qs.parse(body) as T;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported content type: ${contentType}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import * as a from 'node:assert';
|
||||
|
||||
import type { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './ClientOAuth2';
|
||||
import { DEFAULT_HEADERS } from './constants';
|
||||
import { auth, expects, getRequestOptions } from './utils';
|
||||
|
@ -65,17 +67,16 @@ export class ClientOAuth2Token {
|
|||
}
|
||||
|
||||
/**
|
||||
* Refresh a user access token with the supplied token.
|
||||
* Refresh a user access token with the refresh token.
|
||||
* As in RFC 6749 Section 6: https://www.rfc-editor.org/rfc/rfc6749.html#section-6
|
||||
*/
|
||||
async refresh(opts?: ClientOAuth2Options): Promise<ClientOAuth2Token> {
|
||||
const options = { ...this.client.options, ...opts };
|
||||
|
||||
expects(options, 'clientSecret');
|
||||
a.ok(this.refreshToken, 'refreshToken is required');
|
||||
|
||||
if (!this.refreshToken) throw new Error('No refresh token');
|
||||
|
||||
const clientId = options.clientId;
|
||||
const clientSecret = options.clientSecret;
|
||||
const { clientId, clientSecret } = options;
|
||||
const headers = { ...DEFAULT_HEADERS };
|
||||
const body: Record<string, string> = {
|
||||
refresh_token: this.refreshToken,
|
||||
|
@ -99,7 +100,7 @@ export class ClientOAuth2Token {
|
|||
options,
|
||||
);
|
||||
|
||||
const responseData = await this.client.request<ClientOAuth2TokenData>(requestOptions);
|
||||
const responseData = await this.client.accessTokenRequest(requestOptions);
|
||||
return this.client.createToken({ ...this.data, ...responseData });
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import * as qs from 'querystring';
|
||||
|
||||
import type { ClientOAuth2, ClientOAuth2Options } from './ClientOAuth2';
|
||||
import type { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token';
|
||||
import type { ClientOAuth2Token } from './ClientOAuth2Token';
|
||||
import { DEFAULT_HEADERS, DEFAULT_URL_BASE } from './constants';
|
||||
import { auth, expects, getAuthError, getRequestOptions } from './utils';
|
||||
|
||||
|
@ -117,7 +117,7 @@ export class CodeFlow {
|
|||
options,
|
||||
);
|
||||
|
||||
const responseData = await this.client.request<ClientOAuth2TokenData>(requestOptions);
|
||||
const responseData = await this.client.accessTokenRequest(requestOptions);
|
||||
return this.client.createToken(responseData);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { ClientOAuth2 } from './ClientOAuth2';
|
||||
import type { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token';
|
||||
import type { ClientOAuth2Token } from './ClientOAuth2Token';
|
||||
import { DEFAULT_HEADERS } from './constants';
|
||||
import type { Headers } from './types';
|
||||
import { auth, expects, getRequestOptions } from './utils';
|
||||
|
@ -55,7 +55,7 @@ export class CredentialsFlow {
|
|||
options,
|
||||
);
|
||||
|
||||
const responseData = await this.client.request<ClientOAuth2TokenData>(requestOptions);
|
||||
const responseData = await this.client.accessTokenRequest(requestOptions);
|
||||
return this.client.createToken(responseData);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,3 +17,14 @@ export interface OAuth2CredentialData {
|
|||
refresh_token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The response from the OAuth2 server when the access token is not successfully
|
||||
* retrieved. As specified in RFC 6749 Section 5.2:
|
||||
* https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2
|
||||
*/
|
||||
export interface OAuth2AccessTokenErrorResponse extends Record<string, unknown> {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
}
|
||||
|
|
168
packages/@n8n/client-oauth2/test/ClientOAuth2.test.ts
Normal file
168
packages/@n8n/client-oauth2/test/ClientOAuth2.test.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
import axios from 'axios';
|
||||
import nock from 'nock';
|
||||
|
||||
import { ClientOAuth2, ResponseError } from '@/ClientOAuth2';
|
||||
import { ERROR_RESPONSES } from '@/constants';
|
||||
import { auth, AuthError } from '@/utils';
|
||||
|
||||
import * as config from './config';
|
||||
|
||||
describe('ClientOAuth2', () => {
|
||||
const client = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authentication: 'header',
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
describe('accessTokenRequest', () => {
|
||||
const authHeader = auth(config.clientId, config.clientSecret);
|
||||
|
||||
const makeTokenCall = async () =>
|
||||
await client.accessTokenRequest({
|
||||
url: config.accessTokenUri,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: {
|
||||
refresh_token: 'test',
|
||||
grant_type: 'refresh_token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockTokenResponse = ({
|
||||
status = 200,
|
||||
headers,
|
||||
body,
|
||||
}: {
|
||||
status: number;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
}) =>
|
||||
nock(config.baseUrl).post('/login/oauth/access_token').once().reply(status, body, headers);
|
||||
|
||||
it('should send the correct request based on given options', async () => {
|
||||
mockTokenResponse({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const axiosSpy = jest.spyOn(axios, 'request');
|
||||
|
||||
await makeTokenCall();
|
||||
|
||||
expect(axiosSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: config.accessTokenUri,
|
||||
method: 'POST',
|
||||
data: 'refresh_token=test&grant_type=refresh_token',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
}),
|
||||
},
|
||||
{
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
body: JSON.stringify({
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
}),
|
||||
},
|
||||
{
|
||||
contentType: 'application/x-www-form-urlencoded',
|
||||
body: `access_token=${config.accessToken}&refresh_token=${config.refreshToken}`,
|
||||
},
|
||||
])('should parse response with content type $contentType', async ({ contentType, body }) => {
|
||||
mockTokenResponse({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
const response = await makeTokenCall();
|
||||
|
||||
expect(response).toEqual({
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
contentType: 'text/html',
|
||||
body: '<html><body>Hello, world!</body></html>',
|
||||
},
|
||||
{
|
||||
contentType: 'application/xml',
|
||||
body: '<xml><body>Hello, world!</body></xml>',
|
||||
},
|
||||
{
|
||||
contentType: 'text/plain',
|
||||
body: 'Hello, world!',
|
||||
},
|
||||
])('should reject content type $contentType', async ({ contentType, body }) => {
|
||||
mockTokenResponse({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
const result = await makeTokenCall().catch((err) => err);
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect(result.message).toEqual(`Unsupported content type: ${contentType}`);
|
||||
});
|
||||
|
||||
it('should reject 4xx responses with auth errors', async () => {
|
||||
mockTokenResponse({
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ error: 'access_denied' }),
|
||||
});
|
||||
|
||||
const result = await makeTokenCall().catch((err) => err);
|
||||
expect(result).toBeInstanceOf(AuthError);
|
||||
expect(result.message).toEqual(ERROR_RESPONSES.access_denied);
|
||||
expect(result.body).toEqual({ error: 'access_denied' });
|
||||
});
|
||||
|
||||
it('should reject 3xx responses with response errors', async () => {
|
||||
mockTokenResponse({
|
||||
status: 302,
|
||||
headers: {},
|
||||
body: 'Redirected',
|
||||
});
|
||||
|
||||
const result = await makeTokenCall().catch((err) => err);
|
||||
expect(result).toBeInstanceOf(ResponseError);
|
||||
expect(result.message).toEqual('HTTP status 302');
|
||||
expect(result.body).toEqual('Redirected');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -15,7 +15,7 @@ entity { Plaintext | Resolvable }
|
|||
|
||||
resolvableChar { unicodeChar | "}" ![}] | "\\}}" }
|
||||
|
||||
unicodeChar { $[\u0000-\u007C] | $[\u007E-\u20CF] | $[\u{1F300}-\u{1F64F}] | $[\u4E00-\u9FFF] }
|
||||
unicodeChar { $[\u0000-\u007C] | $[\u007E-\u20CF] | $[\u{1F300}-\u{1FAF8}] | $[\u4E00-\u9FFF] }
|
||||
}
|
||||
|
||||
@detectDelim
|
||||
|
|
|
@ -10,7 +10,7 @@ export const parser = LRParser.deserialize({
|
|||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData:
|
||||
"&U~RTO#ob#o#p!h#p;'Sb;'S;=`!]<%lOb~gTQ~O#ob#o#pv#p;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOQ~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TWO#O#Q#O#P#m#P#q#Q#q#r%Z#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~#pWO#O#Q#O#P#m#P#q#Q#q#r$Y#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~$]TO#q#Q#q#r$l#r;'S#Q;'S;=`%r<%lO#Q~$qWR~O#O#Q#O#P#m#P#q#Q#q#r%Z#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~%^TO#q#Q#q#r%m#r;'S#Q;'S;=`%r<%lO#Q~%rOR~~%uP;=`<%l#Q~%{P;NQ<%l#Q~&RP;=`;JY#Q",
|
||||
"&_~RTO#ob#o#p!h#p;'Sb;'S;=`!]<%lOb~gTQ~O#ob#o#pv#p;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOQ~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TXO#O#Q#O#P#p#P#q#Q#q#r%d#r$Ml#Q*5S41d#Q;(b;(c&R;(c;(d%{;(d;(e&X~#sXO#O#Q#O#P#p#P#q#Q#q#r$`#r$Ml#Q*5S41d#Q;(b;(c&R;(c;(d%{;(d;(e&X~$cTO#q#Q#q#r$r#r;'S#Q;'S;=`%{<%lO#Q~$wXR~O#O#Q#O#P#p#P#q#Q#q#r%d#r$Ml#Q*5S41d#Q;(b;(c&R;(c;(d%{;(d;(e&X~%gTO#q#Q#q#r%v#r;'S#Q;'S;=`%{<%lO#Q~%{OR~~&OP;=`<%l#Q~&UP;NQ<%l#Q~&[P;=`;My#Q",
|
||||
tokenizers: [0],
|
||||
topRules: { Program: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
|
|
|
@ -277,3 +277,19 @@ Program(Resolvable)
|
|||
==>
|
||||
|
||||
Program(Resolvable)
|
||||
|
||||
# Resolvable with new emoji range
|
||||
|
||||
{{ '🟢' }}
|
||||
|
||||
==>
|
||||
|
||||
Program(Resolvable)
|
||||
|
||||
# Resolvable with new emoji range end of range
|
||||
|
||||
{{ '🫸' }}
|
||||
|
||||
==>
|
||||
|
||||
Program(Resolvable)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "1.25.0",
|
||||
"version": "1.27.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -107,7 +107,7 @@ class MysqlConfig {
|
|||
}
|
||||
|
||||
@Config
|
||||
class SqliteConfig {
|
||||
export class SqliteConfig {
|
||||
/** SQLite database file name */
|
||||
@Env('DB_SQLITE_DATABASE')
|
||||
database: string = 'database.sqlite';
|
||||
|
|
17
packages/@n8n/config/src/configs/external-hooks.config.ts
Normal file
17
packages/@n8n/config/src/configs/external-hooks.config.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { Config, Env } from '../decorators';
|
||||
|
||||
class ColonSeparatedStringArray<T extends string = string> extends Array<T> {
|
||||
constructor(str: string) {
|
||||
super();
|
||||
const parsed = str.split(':') as this;
|
||||
const filtered = parsed.filter((i) => typeof i === 'string' && i.length);
|
||||
return filtered.length ? filtered : [];
|
||||
}
|
||||
}
|
||||
|
||||
@Config
|
||||
export class ExternalHooksConfig {
|
||||
/** Files containing external hooks. Multiple files can be separated by colon (":") */
|
||||
@Env('EXTERNAL_HOOK_FILES')
|
||||
files: ColonSeparatedStringArray = [];
|
||||
}
|
|
@ -23,11 +23,14 @@ class S3CredentialsConfig {
|
|||
}
|
||||
|
||||
@Config
|
||||
class S3Config {
|
||||
export class S3Config {
|
||||
/** Host of the n8n bucket in S3-compatible external storage @example "s3.us-east-1.amazonaws.com" */
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_HOST')
|
||||
host: string = '';
|
||||
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_PROTOCOL')
|
||||
protocol: 'http' | 'https' = 'https';
|
||||
|
||||
@Nested
|
||||
bucket: S3BucketConfig;
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@ export class TaskRunnersConfig {
|
|||
@Env('N8N_RUNNERS_ENABLED')
|
||||
enabled: boolean = false;
|
||||
|
||||
// Defaults to true for now
|
||||
@Env('N8N_RUNNERS_MODE')
|
||||
mode: TaskRunnerMode = 'internal';
|
||||
|
||||
|
@ -23,12 +22,12 @@ export class TaskRunnersConfig {
|
|||
@Env('N8N_RUNNERS_AUTH_TOKEN')
|
||||
authToken: string = '';
|
||||
|
||||
/** IP address task runners server should listen on */
|
||||
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
|
||||
/** IP address task runners broker should listen on */
|
||||
@Env('N8N_RUNNERS_BROKER_PORT')
|
||||
port: number = 5679;
|
||||
|
||||
/** IP address task runners server should listen on */
|
||||
@Env('N8N_RUNNERS_SERVER_LISTEN_ADDRESS')
|
||||
/** IP address task runners broker should listen on */
|
||||
@Env('N8N_RUNNERS_BROKER_LISTEN_ADDRESS')
|
||||
listenAddress: string = '127.0.0.1';
|
||||
|
||||
/** Maximum size of a payload sent to the runner in bytes, Default 1G */
|
||||
|
|
|
@ -6,6 +6,7 @@ import { DiagnosticsConfig } from './configs/diagnostics.config';
|
|||
import { EndpointsConfig } from './configs/endpoints.config';
|
||||
import { EventBusConfig } from './configs/event-bus.config';
|
||||
import { ExecutionsConfig } from './configs/executions.config';
|
||||
import { ExternalHooksConfig } from './configs/external-hooks.config';
|
||||
import { ExternalSecretsConfig } from './configs/external-secrets.config';
|
||||
import { ExternalStorageConfig } from './configs/external-storage.config';
|
||||
import { GenericConfig } from './configs/generic.config';
|
||||
|
@ -30,6 +31,7 @@ export { TaskRunnersConfig } from './configs/runners.config';
|
|||
export { SecurityConfig } from './configs/security.config';
|
||||
export { ExecutionsConfig } from './configs/executions.config';
|
||||
export { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config';
|
||||
export { S3Config } from './configs/external-storage.config';
|
||||
export { LOG_SCOPES } from './configs/logging.config';
|
||||
export type { LogScope } from './configs/logging.config';
|
||||
|
||||
|
@ -50,6 +52,9 @@ export class GlobalConfig {
|
|||
@Nested
|
||||
publicApi: PublicApiConfig;
|
||||
|
||||
@Nested
|
||||
externalHooks: ExternalHooksConfig;
|
||||
|
||||
@Nested
|
||||
externalSecrets: ExternalSecretsConfig;
|
||||
|
||||
|
|
|
@ -107,6 +107,9 @@ describe('GlobalConfig', () => {
|
|||
maxFileSizeInKB: 10240,
|
||||
},
|
||||
},
|
||||
externalHooks: {
|
||||
files: [],
|
||||
},
|
||||
externalSecrets: {
|
||||
preferGet: false,
|
||||
updateInterval: 300,
|
||||
|
@ -138,6 +141,7 @@ describe('GlobalConfig', () => {
|
|||
externalStorage: {
|
||||
s3: {
|
||||
host: '',
|
||||
protocol: 'https',
|
||||
bucket: {
|
||||
name: '',
|
||||
region: '',
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class DeepSeekApi implements ICredentialType {
|
||||
name = 'deepSeekApi';
|
||||
|
||||
displayName = 'DeepSeek';
|
||||
|
||||
documentationUrl = 'deepseek';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'url',
|
||||
type: 'hidden',
|
||||
default: 'https://api.deepseek.com',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{ $credentials.url }}',
|
||||
url: '/models',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialTestRequest,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class OpenRouterApi implements ICredentialType {
|
||||
name = 'openRouterApi';
|
||||
|
||||
displayName = 'OpenRouter';
|
||||
|
||||
documentationUrl = 'openrouter';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'url',
|
||||
type: 'hidden',
|
||||
default: 'https://openrouter.ai/api/v1',
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: '={{ $credentials.url }}',
|
||||
url: '/models',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -91,6 +91,8 @@ function getInputs(
|
|||
'@n8n/n8n-nodes-langchain.lmChatGoogleVertex',
|
||||
'@n8n/n8n-nodes-langchain.lmChatMistralCloud',
|
||||
'@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
|
||||
'@n8n/n8n-nodes-langchain.lmChatDeepSeek',
|
||||
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@ -119,6 +121,8 @@ function getInputs(
|
|||
'@n8n/n8n-nodes-langchain.lmChatGroq',
|
||||
'@n8n/n8n-nodes-langchain.lmChatGoogleVertex',
|
||||
'@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
|
||||
'@n8n/n8n-nodes-langchain.lmChatDeepSeek',
|
||||
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -147,7 +147,7 @@ export class LmChatOpenAi implements INodeType {
|
|||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
default: { mode: 'list', value: 'gpt-4o-mini' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
|
@ -164,7 +164,7 @@ export class LmChatOpenAi implements INodeType {
|
|||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: '2302163813',
|
||||
placeholder: 'gpt-4o-mini',
|
||||
},
|
||||
],
|
||||
description: 'The model. Choose from the list, or specify an ID.',
|
||||
|
@ -258,7 +258,7 @@ export class LmChatOpenAi implements INodeType {
|
|||
displayName: 'Sampling Temperature',
|
||||
name: 'temperature',
|
||||
default: 0.7,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
|
||||
type: 'number',
|
||||
|
|
|
@ -0,0 +1,253 @@
|
|||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
|
||||
import { ChatOpenAI, type ClientOptions } from '@langchain/openai';
|
||||
import {
|
||||
NodeConnectionType,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
||||
|
||||
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
|
||||
export class LmChatDeepSeek implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'DeepSeek Chat Model',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-name-miscased
|
||||
name: 'lmChatDeepSeek',
|
||||
icon: 'file:deepseek.svg',
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'DeepSeek Chat Model',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Language Models', 'Root Nodes'],
|
||||
'Language Models': ['Chat Models (Recommended)'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatdeepseek/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
|
||||
inputs: [],
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
|
||||
outputs: [NodeConnectionType.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'deepSeekApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: '={{ $credentials?.url }}',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]),
|
||||
{
|
||||
displayName:
|
||||
'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.responseFormat': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://api-docs.deepseek.com/quick_start/pricing">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'deepseek-chat',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
name: 'frequencyPenalty',
|
||||
default: 0,
|
||||
typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 },
|
||||
description:
|
||||
"Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim",
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokens',
|
||||
default: -1,
|
||||
description:
|
||||
'The maximum number of tokens to generate in the completion. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Response Format',
|
||||
name: 'responseFormat',
|
||||
default: 'text',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
description: 'Regular text response',
|
||||
},
|
||||
{
|
||||
name: 'JSON',
|
||||
value: 'json_object',
|
||||
description:
|
||||
'Enables JSON mode, which should guarantee the message the model generates is valid JSON',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Presence Penalty',
|
||||
name: 'presencePenalty',
|
||||
default: 0,
|
||||
typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 },
|
||||
description:
|
||||
"Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics",
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Sampling Temperature',
|
||||
name: 'temperature',
|
||||
default: 0.7,
|
||||
typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
default: 360000,
|
||||
description: 'Maximum amount of time a request is allowed to take in milliseconds',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Retries',
|
||||
name: 'maxRetries',
|
||||
default: 2,
|
||||
description: 'Maximum number of retries to attempt',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Top P',
|
||||
name: 'topP',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials<OpenAICompatibleCredential>('deepSeekApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
frequencyPenalty?: number;
|
||||
maxTokens?: number;
|
||||
maxRetries: number;
|
||||
timeout: number;
|
||||
presencePenalty?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
responseFormat?: 'text' | 'json_object';
|
||||
};
|
||||
|
||||
const configuration: ClientOptions = {
|
||||
baseURL: credentials.url,
|
||||
};
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
openAIApiKey: credentials.apiKey,
|
||||
modelName,
|
||||
...options,
|
||||
timeout: options.timeout ?? 60000,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
configuration,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
modelKwargs: options.responseFormat
|
||||
? {
|
||||
response_format: { type: options.responseFormat },
|
||||
}
|
||||
: undefined,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
After Width: | Height: | Size: 2.1 KiB |
|
@ -0,0 +1,252 @@
|
|||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
|
||||
import { ChatOpenAI, type ClientOptions } from '@langchain/openai';
|
||||
import {
|
||||
NodeConnectionType,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
||||
|
||||
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
|
||||
export class LmChatOpenRouter implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'OpenRouter Chat Model',
|
||||
name: 'lmChatOpenRouter',
|
||||
icon: { light: 'file:openrouter.svg', dark: 'file:openrouter.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: [1],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'OpenRouter Chat Model',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Language Models', 'Root Nodes'],
|
||||
'Language Models': ['Chat Models (Recommended)'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatopenrouter/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
|
||||
inputs: [],
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
|
||||
outputs: [NodeConnectionType.AiLanguageModel],
|
||||
outputNames: ['Model'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'openRouterApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
requestDefaults: {
|
||||
ignoreHttpStatusErrors: true,
|
||||
baseURL: '={{ $credentials?.url }}',
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]),
|
||||
{
|
||||
displayName:
|
||||
'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/options.responseFormat': ['json_object'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'options',
|
||||
description:
|
||||
'The model which will generate the completion. <a href="https://openrouter.ai/docs/models">Learn more</a>.',
|
||||
typeOptions: {
|
||||
loadOptions: {
|
||||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
{
|
||||
type: 'rootProperty',
|
||||
properties: {
|
||||
property: 'data',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'setKeyValue',
|
||||
properties: {
|
||||
name: '={{$responseItem.id}}',
|
||||
value: '={{$responseItem.id}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
send: {
|
||||
type: 'body',
|
||||
property: 'model',
|
||||
},
|
||||
},
|
||||
default: 'openai/gpt-4o-mini',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
description: 'Additional options to add',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Frequency Penalty',
|
||||
name: 'frequencyPenalty',
|
||||
default: 0,
|
||||
typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 },
|
||||
description:
|
||||
"Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim",
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Maximum Number of Tokens',
|
||||
name: 'maxTokens',
|
||||
default: -1,
|
||||
description:
|
||||
'The maximum number of tokens to generate in the completion. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
maxValue: 32768,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Response Format',
|
||||
name: 'responseFormat',
|
||||
default: 'text',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
description: 'Regular text response',
|
||||
},
|
||||
{
|
||||
name: 'JSON',
|
||||
value: 'json_object',
|
||||
description:
|
||||
'Enables JSON mode, which should guarantee the message the model generates is valid JSON',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Presence Penalty',
|
||||
name: 'presencePenalty',
|
||||
default: 0,
|
||||
typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 },
|
||||
description:
|
||||
"Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics",
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Sampling Temperature',
|
||||
name: 'temperature',
|
||||
default: 0.7,
|
||||
typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
default: 360000,
|
||||
description: 'Maximum amount of time a request is allowed to take in milliseconds',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Retries',
|
||||
name: 'maxRetries',
|
||||
default: 2,
|
||||
description: 'Maximum number of retries to attempt',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
displayName: 'Top P',
|
||||
name: 'topP',
|
||||
default: 1,
|
||||
typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 },
|
||||
description:
|
||||
'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials<OpenAICompatibleCredential>('openRouterApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
frequencyPenalty?: number;
|
||||
maxTokens?: number;
|
||||
maxRetries: number;
|
||||
timeout: number;
|
||||
presencePenalty?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
responseFormat?: 'text' | 'json_object';
|
||||
};
|
||||
|
||||
const configuration: ClientOptions = {
|
||||
baseURL: credentials.url,
|
||||
};
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
openAIApiKey: credentials.apiKey,
|
||||
modelName,
|
||||
...options,
|
||||
timeout: options.timeout ?? 60000,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
configuration,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
modelKwargs: options.responseFormat
|
||||
? {
|
||||
response_format: { type: options.responseFormat },
|
||||
}
|
||||
: undefined,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<svg fill="white" fill-rule="evenodd" width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>
|
After Width: | Height: | Size: 866 B |
|
@ -0,0 +1 @@
|
|||
<svg fill="#94A3B8" fill-rule="evenodd" width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>
|
After Width: | Height: | Size: 868 B |
|
@ -1,4 +1,3 @@
|
|||
import { loadWorkflowInputMappings } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions';
|
||||
import type {
|
||||
INodeTypeBaseDescription,
|
||||
ISupplyDataFunctions,
|
||||
|
@ -7,6 +6,7 @@ import type {
|
|||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { localResourceMapping } from './methods';
|
||||
import { WorkflowToolService } from './utils/WorkflowToolService';
|
||||
import { versionDescription } from './versionDescription';
|
||||
|
||||
|
@ -21,9 +21,7 @@ export class ToolWorkflowV2 implements INodeType {
|
|||
}
|
||||
|
||||
methods = {
|
||||
localResourceMapping: {
|
||||
loadWorkflowInputMappings,
|
||||
},
|
||||
localResourceMapping,
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * as localResourceMapping from './localResourceMapping';
|
|
@ -0,0 +1,17 @@
|
|||
import { loadWorkflowInputMappings } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions';
|
||||
import type { ILocalLoadOptionsFunctions, ResourceMapperFields } from 'n8n-workflow';
|
||||
|
||||
export async function loadSubWorkflowInputs(
|
||||
this: ILocalLoadOptionsFunctions,
|
||||
): Promise<ResourceMapperFields> {
|
||||
const { fields, subworkflowInfo } = await loadWorkflowInputMappings.bind(this)();
|
||||
let emptyFieldsNotice: string | undefined;
|
||||
if (fields.length === 0) {
|
||||
const subworkflowLink = subworkflowInfo?.id
|
||||
? `<a href="/workflow/${subworkflowInfo?.id}" target="_blank">sub-workflow’s trigger</a>`
|
||||
: 'sub-workflow’s trigger';
|
||||
|
||||
emptyFieldsNotice = `This sub-workflow will not receive any input when called by your AI node. Define your expected input in the ${subworkflowLink}.`;
|
||||
}
|
||||
return { fields, emptyFieldsNotice };
|
||||
}
|
|
@ -107,7 +107,7 @@ export const versionDescription: INodeTypeDescription = {
|
|||
typeOptions: {
|
||||
loadOptionsDependsOn: ['workflowId.value'],
|
||||
resourceMapper: {
|
||||
localResourceMapperMethod: 'loadWorkflowInputMappings',
|
||||
localResourceMapperMethod: 'loadSubWorkflowInputs',
|
||||
valuesLabel: 'Workflow Inputs',
|
||||
mode: 'map',
|
||||
fieldWords: {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import type { MemoryVectorStore } from 'langchain/vectorstores/memory';
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { createVectorStoreNode } from '../shared/createVectorStoreNode';
|
||||
|
@ -20,7 +21,7 @@ const insertFields: INodeProperties[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export class VectorStoreInMemory extends createVectorStoreNode({
|
||||
export class VectorStoreInMemory extends createVectorStoreNode<MemoryVectorStore>({
|
||||
meta: {
|
||||
displayName: 'In-Memory Vector Store',
|
||||
name: 'vectorStoreInMemory',
|
||||
|
|
|
@ -213,7 +213,7 @@ class ExtendedPGVectorStore extends PGVectorStore {
|
|||
}
|
||||
}
|
||||
|
||||
export class VectorStorePGVector extends createVectorStoreNode({
|
||||
export class VectorStorePGVector extends createVectorStoreNode<ExtendedPGVectorStore>({
|
||||
meta: {
|
||||
description: 'Work with your data in Postgresql with the PGVector extension',
|
||||
icon: 'file:postgres.svg',
|
||||
|
@ -274,6 +274,7 @@ export class VectorStorePGVector extends createVectorStoreNode({
|
|||
|
||||
return await ExtendedPGVectorStore.initialize(embeddings, config);
|
||||
},
|
||||
|
||||
async populateVectorStore(context, embeddings, documents, itemIndex) {
|
||||
// NOTE: if you are to create the HNSW index before use, you need to consider moving the distanceStrategy field to
|
||||
// shared fields, because you need that strategy when creating the index.
|
||||
|
@ -307,6 +308,11 @@ export class VectorStorePGVector extends createVectorStoreNode({
|
|||
metadataColumnName: 'metadata',
|
||||
}) as ColumnOptions;
|
||||
|
||||
await PGVectorStore.fromDocuments(documents, embeddings, config);
|
||||
const vectorStore = await PGVectorStore.fromDocuments(documents, embeddings, config);
|
||||
vectorStore.client?.release();
|
||||
},
|
||||
|
||||
releaseVectorStoreClient(vectorStore) {
|
||||
vectorStore.client?.release();
|
||||
},
|
||||
}) {}
|
||||
|
|
|
@ -51,7 +51,7 @@ const insertFields: INodeProperties[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export class VectorStorePinecone extends createVectorStoreNode({
|
||||
export class VectorStorePinecone extends createVectorStoreNode<PineconeStore>({
|
||||
meta: {
|
||||
displayName: 'Pinecone Vector Store',
|
||||
name: 'vectorStorePinecone',
|
||||
|
|
|
@ -79,7 +79,7 @@ const retrieveFields: INodeProperties[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export class VectorStoreQdrant extends createVectorStoreNode({
|
||||
export class VectorStoreQdrant extends createVectorStoreNode<ExtendedQdrantVectorStore>({
|
||||
meta: {
|
||||
displayName: 'Qdrant Vector Store',
|
||||
name: 'vectorStoreQdrant',
|
||||
|
|
|
@ -41,7 +41,7 @@ const retrieveFields: INodeProperties[] = [
|
|||
|
||||
const updateFields: INodeProperties[] = [...insertFields];
|
||||
|
||||
export class VectorStoreSupabase extends createVectorStoreNode({
|
||||
export class VectorStoreSupabase extends createVectorStoreNode<SupabaseVectorStore>({
|
||||
meta: {
|
||||
description: 'Work with your data in Supabase Vector Store',
|
||||
icon: 'file:supabase.svg',
|
||||
|
|
|
@ -46,7 +46,7 @@ const retrieveFields: INodeProperties[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export class VectorStoreZep extends createVectorStoreNode({
|
||||
export class VectorStoreZep extends createVectorStoreNode<ZepVectorStore | ZepCloudVectorStore>({
|
||||
meta: {
|
||||
displayName: 'Zep Vector Store',
|
||||
name: 'vectorStoreZep',
|
||||
|
|
|
@ -49,7 +49,7 @@ interface NodeMeta {
|
|||
operationModes?: NodeOperationMode[];
|
||||
}
|
||||
|
||||
export interface VectorStoreNodeConstructorArgs {
|
||||
export interface VectorStoreNodeConstructorArgs<T extends VectorStore = VectorStore> {
|
||||
meta: NodeMeta;
|
||||
methods?: {
|
||||
listSearch?: {
|
||||
|
@ -77,7 +77,8 @@ export interface VectorStoreNodeConstructorArgs {
|
|||
filter: Record<string, never> | undefined,
|
||||
embeddings: Embeddings,
|
||||
itemIndex: number,
|
||||
) => Promise<VectorStore>;
|
||||
) => Promise<T>;
|
||||
releaseVectorStoreClient?: (vectorStore: T) => void;
|
||||
}
|
||||
|
||||
function transformDescriptionForOperationMode(
|
||||
|
@ -90,11 +91,15 @@ function transformDescriptionForOperationMode(
|
|||
}));
|
||||
}
|
||||
|
||||
function isUpdateSupported(args: VectorStoreNodeConstructorArgs): boolean {
|
||||
function isUpdateSupported<T extends VectorStore>(
|
||||
args: VectorStoreNodeConstructorArgs<T>,
|
||||
): boolean {
|
||||
return args.meta.operationModes?.includes('update') ?? false;
|
||||
}
|
||||
|
||||
function getOperationModeOptions(args: VectorStoreNodeConstructorArgs): INodePropertyOptions[] {
|
||||
function getOperationModeOptions<T extends VectorStore>(
|
||||
args: VectorStoreNodeConstructorArgs<T>,
|
||||
): INodePropertyOptions[] {
|
||||
const enabledOperationModes = args.meta.operationModes ?? DEFAULT_OPERATION_MODES;
|
||||
|
||||
const allOptions = [
|
||||
|
@ -137,7 +142,9 @@ function getOperationModeOptions(args: VectorStoreNodeConstructorArgs): INodePro
|
|||
);
|
||||
}
|
||||
|
||||
export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
||||
export const createVectorStoreNode = <T extends VectorStore = VectorStore>(
|
||||
args: VectorStoreNodeConstructorArgs<T>,
|
||||
) =>
|
||||
class VectorStoreNodeType implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: args.meta.displayName,
|
||||
|
@ -334,6 +341,7 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
embeddings,
|
||||
itemIndex,
|
||||
);
|
||||
try {
|
||||
const prompt = this.getNodeParameter('prompt', itemIndex) as string;
|
||||
const topK = this.getNodeParameter('topK', itemIndex, 4) as number;
|
||||
|
||||
|
@ -366,6 +374,9 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
|
||||
resultData.push(...serializedDocs);
|
||||
logAiEvent(this, 'ai-vector-store-searched', { query: prompt });
|
||||
} finally {
|
||||
args.releaseVectorStoreClient?.(vectorStore);
|
||||
}
|
||||
}
|
||||
|
||||
return [resultData];
|
||||
|
@ -392,13 +403,9 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
);
|
||||
resultData.push(...serializedDocuments);
|
||||
|
||||
try {
|
||||
await args.populateVectorStore(this, embeddings, processedDocuments, itemIndex);
|
||||
|
||||
logAiEvent(this, 'ai-vector-store-populated');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return [resultData];
|
||||
|
@ -431,6 +438,7 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
itemIndex,
|
||||
);
|
||||
|
||||
try {
|
||||
const { processedDocuments, serializedDocuments } = await processDocument(
|
||||
loader,
|
||||
itemData,
|
||||
|
@ -443,15 +451,14 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
|
||||
resultData.push(...serializedDocuments);
|
||||
|
||||
try {
|
||||
// Use ids option to upsert instead of insert
|
||||
await vectorStore.addDocuments(processedDocuments, {
|
||||
ids: [documentId],
|
||||
});
|
||||
|
||||
logAiEvent(this, 'ai-vector-store-updated');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
args.releaseVectorStoreClient?.(vectorStore);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -476,6 +483,9 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
const vectorStore = await args.getVectorStoreClient(this, filter, embeddings, itemIndex);
|
||||
return {
|
||||
response: logWrapper(vectorStore, this),
|
||||
closeFunction: async () => {
|
||||
args.releaseVectorStoreClient?.(vectorStore);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -499,6 +509,8 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
embeddings,
|
||||
itemIndex,
|
||||
);
|
||||
|
||||
try {
|
||||
const embeddedPrompt = await embeddings.embedQuery(input);
|
||||
const documents = await vectorStore.similaritySearchVectorWithScore(
|
||||
embeddedPrompt,
|
||||
|
@ -516,6 +528,9 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
};
|
||||
})
|
||||
.filter((document) => !!document);
|
||||
} finally {
|
||||
args.releaseVectorStoreClient?.(vectorStore);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "1.75.0",
|
||||
"version": "1.77.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm run watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && pnpm n8n-copy-icons && pnpm n8n-generate-metadata",
|
||||
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && pnpm n8n-copy-static-files && pnpm n8n-generate-metadata",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"lint": "eslint nodes credentials utils --quiet",
|
||||
|
@ -25,12 +25,14 @@
|
|||
"dist/credentials/AnthropicApi.credentials.js",
|
||||
"dist/credentials/AzureOpenAiApi.credentials.js",
|
||||
"dist/credentials/CohereApi.credentials.js",
|
||||
"dist/credentials/DeepSeekApi.credentials.js",
|
||||
"dist/credentials/GooglePalmApi.credentials.js",
|
||||
"dist/credentials/GroqApi.credentials.js",
|
||||
"dist/credentials/HuggingFaceApi.credentials.js",
|
||||
"dist/credentials/MotorheadApi.credentials.js",
|
||||
"dist/credentials/MistralCloudApi.credentials.js",
|
||||
"dist/credentials/OllamaApi.credentials.js",
|
||||
"dist/credentials/OpenRouterApi.credentials.js",
|
||||
"dist/credentials/PineconeApi.credentials.js",
|
||||
"dist/credentials/QdrantApi.credentials.js",
|
||||
"dist/credentials/SerpApi.credentials.js",
|
||||
|
@ -64,11 +66,13 @@
|
|||
"dist/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.js",
|
||||
"dist/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.js",
|
||||
"dist/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.js",
|
||||
"dist/nodes/llms/LmChatDeepSeek/LmChatDeepSeek.node.js",
|
||||
"dist/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.js",
|
||||
"dist/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.js",
|
||||
"dist/nodes/llms/LmChatGroq/LmChatGroq.node.js",
|
||||
"dist/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.js",
|
||||
"dist/nodes/llms/LMChatOllama/LmChatOllama.node.js",
|
||||
"dist/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.js",
|
||||
"dist/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.js",
|
||||
"dist/nodes/llms/LMOpenAi/LmOpenAi.node.js",
|
||||
"dist/nodes/llms/LMCohere/LmCohere.node.js",
|
||||
|
|
1
packages/@n8n/nodes-langchain/types/types.ts
Normal file
1
packages/@n8n/nodes-langchain/types/types.ts
Normal file
|
@ -0,0 +1 @@
|
|||
type OpenAICompatibleCredential = { apiKey: string; url: string };
|
|
@ -3,19 +3,19 @@
|
|||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^3.2.2",
|
||||
"@storybook/addon-a11y": "^8.4.6",
|
||||
"@storybook/addon-actions": "^8.4.6",
|
||||
"@storybook/addon-docs": "^8.4.6",
|
||||
"@storybook/addon-essentials": "^8.4.6",
|
||||
"@storybook/addon-interactions": "^8.4.6",
|
||||
"@storybook/addon-links": "^8.4.6",
|
||||
"@storybook/addon-themes": "^8.4.6",
|
||||
"@storybook/blocks": "^8.4.6",
|
||||
"@storybook/test": "^8.4.6",
|
||||
"@storybook/vue3": "^8.4.6",
|
||||
"@storybook/vue3-vite": "^8.4.6",
|
||||
"chromatic": "^11.20.0",
|
||||
"storybook": "^8.4.6"
|
||||
"@chromatic-com/storybook": "^3.2.4",
|
||||
"@storybook/addon-a11y": "^8.5.0",
|
||||
"@storybook/addon-actions": "^8.5.0",
|
||||
"@storybook/addon-docs": "^8.5.0",
|
||||
"@storybook/addon-essentials": "^8.5.0",
|
||||
"@storybook/addon-interactions": "^8.5.0",
|
||||
"@storybook/addon-links": "^8.5.0",
|
||||
"@storybook/addon-themes": "^8.5.0",
|
||||
"@storybook/blocks": "^8.5.0",
|
||||
"@storybook/test": "^8.5.0",
|
||||
"@storybook/vue3": "^8.5.0",
|
||||
"@storybook/vue3-vite": "^8.5.0",
|
||||
"chromatic": "^11.25.0",
|
||||
"storybook": "^8.5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "1.13.0",
|
||||
"version": "1.15.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import { ApplicationError } from 'n8n-workflow';
|
||||
|
||||
import { ExecutionError } from '@/js-task-runner/errors/execution-error';
|
||||
|
||||
import { createRequireResolver, type RequireResolverOpts } from '../require-resolver';
|
||||
|
||||
describe('require resolver', () => {
|
||||
let defaultOpts: RequireResolverOpts;
|
||||
|
||||
beforeEach(() => {
|
||||
defaultOpts = {
|
||||
allowedBuiltInModules: new Set(['path', 'fs']),
|
||||
allowedExternalModules: new Set(['lodash']),
|
||||
};
|
||||
});
|
||||
|
||||
describe('built-in modules', () => {
|
||||
it('should allow requiring whitelisted built-in modules', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('path')).not.toThrow();
|
||||
expect(() => resolver('fs')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when requiring non-whitelisted built-in modules', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('crypto')).toThrow(ExecutionError);
|
||||
});
|
||||
|
||||
it('should allow all built-in modules when allowedBuiltInModules is "*"', () => {
|
||||
const resolver = createRequireResolver({
|
||||
...defaultOpts,
|
||||
allowedBuiltInModules: '*',
|
||||
});
|
||||
|
||||
expect(() => resolver('path')).not.toThrow();
|
||||
expect(() => resolver('crypto')).not.toThrow();
|
||||
expect(() => resolver('fs')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('external modules', () => {
|
||||
it('should allow requiring whitelisted external modules', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('lodash')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when requiring non-whitelisted external modules', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('express')).toThrow(
|
||||
new ExecutionError(new ApplicationError("Cannot find module 'express'")),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow all external modules when allowedExternalModules is "*"', () => {
|
||||
const resolver = createRequireResolver({
|
||||
...defaultOpts,
|
||||
allowedExternalModules: '*',
|
||||
});
|
||||
|
||||
expect(() => resolver('lodash')).not.toThrow();
|
||||
expect(() => resolver('express')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should wrap ApplicationError in ExecutionError', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('non-existent-module')).toThrow(ExecutionError);
|
||||
});
|
||||
|
||||
it('should include the module name in the error message', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('non-existent-module')).toThrow(
|
||||
"Cannot find module 'non-existent-module'",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -98,17 +98,40 @@ export class JsTaskRunner extends TaskRunner {
|
|||
const { jsRunnerConfig } = config;
|
||||
|
||||
const parseModuleAllowList = (moduleList: string) =>
|
||||
moduleList === '*' ? null : new Set(moduleList.split(',').map((x) => x.trim()));
|
||||
moduleList === '*'
|
||||
? '*'
|
||||
: new Set(
|
||||
moduleList
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter((x) => x !== ''),
|
||||
);
|
||||
|
||||
const allowedBuiltInModules = parseModuleAllowList(jsRunnerConfig.allowedBuiltInModules ?? '');
|
||||
const allowedExternalModules = parseModuleAllowList(
|
||||
jsRunnerConfig.allowedExternalModules ?? '',
|
||||
);
|
||||
|
||||
this.requireResolver = createRequireResolver({
|
||||
allowedBuiltInModules: parseModuleAllowList(jsRunnerConfig.allowedBuiltInModules ?? ''),
|
||||
allowedExternalModules: parseModuleAllowList(jsRunnerConfig.allowedExternalModules ?? ''),
|
||||
allowedBuiltInModules,
|
||||
allowedExternalModules,
|
||||
});
|
||||
|
||||
this.preventPrototypePollution();
|
||||
this.preventPrototypePollution(allowedExternalModules);
|
||||
}
|
||||
|
||||
private preventPrototypePollution(allowedExternalModules: Set<string> | '*') {
|
||||
if (allowedExternalModules instanceof Set) {
|
||||
// This is a workaround to enable the allowed external libraries to mutate
|
||||
// prototypes directly. For example momentjs overrides .toString() directly
|
||||
// on the Moment.prototype, which doesn't work if Object.prototype has been
|
||||
// frozen. This works as long as the overrides are done when the library is
|
||||
// imported.
|
||||
for (const module of allowedExternalModules) {
|
||||
require(module);
|
||||
}
|
||||
}
|
||||
|
||||
private preventPrototypePollution() {
|
||||
// Freeze globals, except for Jest
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
Object.getOwnPropertyNames(globalThis)
|
||||
|
|
|
@ -6,15 +6,15 @@ import { ExecutionError } from './errors/execution-error';
|
|||
export type RequireResolverOpts = {
|
||||
/**
|
||||
* List of built-in nodejs modules that are allowed to be required in the
|
||||
* execution sandbox. `null` means all are allowed.
|
||||
* execution sandbox. `"*"` means all are allowed.
|
||||
*/
|
||||
allowedBuiltInModules: Set<string> | null;
|
||||
allowedBuiltInModules: Set<string> | '*';
|
||||
|
||||
/**
|
||||
* List of external modules that are allowed to be required in the
|
||||
* execution sandbox. `null` means all are allowed.
|
||||
* execution sandbox. `"*"` means all are allowed.
|
||||
*/
|
||||
allowedExternalModules: Set<string> | null;
|
||||
allowedExternalModules: Set<string> | '*';
|
||||
};
|
||||
|
||||
export type RequireResolver = (request: string) => unknown;
|
||||
|
@ -24,8 +24,8 @@ export function createRequireResolver({
|
|||
allowedExternalModules,
|
||||
}: RequireResolverOpts) {
|
||||
return (request: string) => {
|
||||
const checkIsAllowed = (allowList: Set<string> | null, moduleName: string) => {
|
||||
return allowList ? allowList.has(moduleName) : true;
|
||||
const checkIsAllowed = (allowList: Set<string> | '*', moduleName: string) => {
|
||||
return allowList === '*' || allowList.has(moduleName);
|
||||
};
|
||||
|
||||
const isAllowed = isBuiltin(request)
|
||||
|
|
|
@ -369,6 +369,8 @@ const config = (module.exports = {
|
|||
|
||||
'n8n-local-rules/no-unused-param-in-catch-clause': 'error',
|
||||
|
||||
'n8n-local-rules/no-useless-catch-throw': 'error',
|
||||
|
||||
'n8n-local-rules/no-plain-errors': 'error',
|
||||
|
||||
// ******************************************************************
|
||||
|
|
|
@ -172,6 +172,49 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
|
||||
'no-useless-catch-throw': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow `try-catch` blocks where the `catch` only contains a `throw error`.',
|
||||
recommended: 'error',
|
||||
},
|
||||
messages: {
|
||||
noUselessCatchThrow: 'Remove useless `catch` block.',
|
||||
},
|
||||
fixable: 'code',
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
CatchClause(node) {
|
||||
if (
|
||||
node.body.body.length === 1 &&
|
||||
node.body.body[0].type === 'ThrowStatement' &&
|
||||
node.body.body[0].argument.type === 'Identifier' &&
|
||||
node.body.body[0].argument.name === node.param.name
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noUselessCatchThrow',
|
||||
fix(fixer) {
|
||||
const tryStatement = node.parent;
|
||||
const tryBlock = tryStatement.block;
|
||||
const sourceCode = context.getSourceCode();
|
||||
const tryBlockText = sourceCode.getText(tryBlock);
|
||||
const tryBlockTextWithoutBraces = tryBlockText.slice(1, -1).trim();
|
||||
const indentedTryBlockText = tryBlockTextWithoutBraces
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\t/, ''))
|
||||
.join('\n');
|
||||
return fixer.replaceText(tryStatement, indentedTryBlockText);
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
'no-skipped-tests': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
|
|
|
@ -51,3 +51,33 @@ ruleTester.run('no-json-parse-json-stringify', rules['no-json-parse-json-stringi
|
|||
},
|
||||
],
|
||||
});
|
||||
|
||||
ruleTester.run('no-useless-catch-throw', rules['no-useless-catch-throw'], {
|
||||
valid: [
|
||||
{
|
||||
code: 'try { foo(); } catch (e) { console.error(e); }',
|
||||
},
|
||||
{
|
||||
code: 'try { foo(); } catch (e) { throw new Error("Custom error"); }',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
try {
|
||||
// Some comment
|
||||
if (foo) {
|
||||
bar();
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}`,
|
||||
errors: [{ messageId: 'noUselessCatchThrow' }],
|
||||
output: `
|
||||
// Some comment
|
||||
if (foo) {
|
||||
bar();
|
||||
}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "1.75.0",
|
||||
"version": "1.77.0",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
@ -96,7 +96,7 @@
|
|||
"@n8n/task-runner": "workspace:*",
|
||||
"@n8n/typeorm": "0.3.20-12",
|
||||
"@n8n_io/ai-assistant-sdk": "1.13.0",
|
||||
"@n8n_io/license-sdk": "2.14.0",
|
||||
"@n8n_io/license-sdk": "2.15.0",
|
||||
"@oclif/core": "4.0.7",
|
||||
"@rudderstack/rudder-sdk-node": "2.0.9",
|
||||
"@sentry/node": "catalog:",
|
||||
|
|
125
packages/cli/src/__tests__/external-hooks.test.ts
Normal file
125
packages/cli/src/__tests__/external-hooks.test.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
import type { GlobalConfig } from '@n8n/config';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { ErrorReporter, Logger } from 'n8n-core';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
|
||||
import type { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||
import type { SettingsRepository } from '@/databases/repositories/settings.repository';
|
||||
import type { UserRepository } from '@/databases/repositories/user.repository';
|
||||
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
|
||||
describe('ExternalHooks', () => {
|
||||
const logger = mock<Logger>();
|
||||
const errorReporter = mock<ErrorReporter>();
|
||||
const globalConfig = mock<GlobalConfig>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const settingsRepository = mock<SettingsRepository>();
|
||||
const credentialsRepository = mock<CredentialsRepository>();
|
||||
const workflowRepository = mock<WorkflowRepository>();
|
||||
|
||||
const workflowData = mock<IWorkflowBase>({ id: '123', name: 'Test Workflow' });
|
||||
const hookFn = jest.fn();
|
||||
|
||||
let externalHooks: ExternalHooks;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
globalConfig.externalHooks.files = [];
|
||||
externalHooks = new ExternalHooks(
|
||||
logger,
|
||||
errorReporter,
|
||||
globalConfig,
|
||||
userRepository,
|
||||
settingsRepository,
|
||||
credentialsRepository,
|
||||
workflowRepository,
|
||||
);
|
||||
});
|
||||
|
||||
describe('init()', () => {
|
||||
it('should not load hooks if no external hook files are configured', async () => {
|
||||
// @ts-expect-error private method
|
||||
const loadHooksSpy = jest.spyOn(externalHooks, 'loadHooks');
|
||||
await externalHooks.init();
|
||||
expect(loadHooksSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if hook file cannot be loaded', async () => {
|
||||
globalConfig.externalHooks.files = ['/path/to/non-existent-hook.js'];
|
||||
|
||||
jest.mock(
|
||||
'/path/to/non-existent-hook.js',
|
||||
() => {
|
||||
throw new Error('File not found');
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
await expect(externalHooks.init()).rejects.toThrow(ApplicationError);
|
||||
});
|
||||
|
||||
it('should successfully load hooks from valid hook file', async () => {
|
||||
const mockHookFile = {
|
||||
workflow: {
|
||||
create: [hookFn],
|
||||
},
|
||||
};
|
||||
|
||||
globalConfig.externalHooks.files = ['/path/to/valid-hook.js'];
|
||||
jest.mock('/path/to/valid-hook.js', () => mockHookFile, { virtual: true });
|
||||
|
||||
await externalHooks.init();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/dot-notation
|
||||
expect(externalHooks['registered']['workflow.create']).toHaveLength(1);
|
||||
|
||||
await externalHooks.run('workflow.create', [workflowData]);
|
||||
|
||||
expect(hookFn).toHaveBeenCalledTimes(1);
|
||||
expect(hookFn).toHaveBeenCalledWith(workflowData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run()', () => {
|
||||
it('should not throw if no hooks are registered', async () => {
|
||||
await externalHooks.run('n8n.stop');
|
||||
});
|
||||
|
||||
it('should execute registered hooks', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/dot-notation
|
||||
externalHooks['registered']['workflow.create'] = [hookFn];
|
||||
|
||||
await externalHooks.run('workflow.create', [workflowData]);
|
||||
|
||||
expect(hookFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const hookInvocationContext = hookFn.mock.instances[0];
|
||||
expect(hookInvocationContext).toHaveProperty('dbCollections');
|
||||
expect(hookInvocationContext.dbCollections).toEqual({
|
||||
User: userRepository,
|
||||
Settings: settingsRepository,
|
||||
Credentials: credentialsRepository,
|
||||
Workflow: workflowRepository,
|
||||
});
|
||||
});
|
||||
|
||||
it('should report error if hook execution fails', async () => {
|
||||
hookFn.mockRejectedValueOnce(new Error('Hook failed'));
|
||||
// eslint-disable-next-line @typescript-eslint/dot-notation
|
||||
externalHooks['registered']['workflow.create'] = [hookFn];
|
||||
|
||||
await expect(externalHooks.run('workflow.create', [workflowData])).rejects.toThrow(
|
||||
ApplicationError,
|
||||
);
|
||||
|
||||
expect(errorReporter.error).toHaveBeenCalledWith(expect.any(ApplicationError), {
|
||||
level: 'fatal',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'There was a problem running hook "workflow.create"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -16,6 +16,12 @@ const MOCK_ACTIVATION_KEY = 'activation-key';
|
|||
const MOCK_FEATURE_FLAG = 'feat:sharing';
|
||||
const MOCK_MAIN_PLAN_ID = '1b765dc4-d39d-4ffe-9885-c56dd67c4b26';
|
||||
|
||||
function makeDateWithHourOffset(offsetInHours: number): Date {
|
||||
const date = new Date();
|
||||
date.setHours(date.getHours() + offsetInHours);
|
||||
return date;
|
||||
}
|
||||
|
||||
const licenseConfig: GlobalConfig['license'] = {
|
||||
serverUrl: MOCK_SERVER_URL,
|
||||
autoRenewalEnabled: true,
|
||||
|
@ -134,7 +140,7 @@ describe('License', () => {
|
|||
expect(LicenseManager.prototype.getManagementJwt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('getMainPlan() returns the right entitlement', async () => {
|
||||
test('getMainPlan() returns the latest main entitlement', async () => {
|
||||
// mock entitlements response
|
||||
License.prototype.getCurrentEntitlements = jest.fn().mockReturnValue([
|
||||
{
|
||||
|
@ -143,8 +149,21 @@ describe('License', () => {
|
|||
productMetadata: {},
|
||||
features: {},
|
||||
featureOverrides: {},
|
||||
validFrom: new Date(),
|
||||
validTo: new Date(),
|
||||
validFrom: makeDateWithHourOffset(-3),
|
||||
validTo: makeDateWithHourOffset(1),
|
||||
},
|
||||
{
|
||||
id: '95b9c852-1349-478d-9ad1-b3f55510e488',
|
||||
productId: '670650f2-72d8-4397-898c-c249906e2cc2',
|
||||
productMetadata: {
|
||||
terms: {
|
||||
isMainPlan: true,
|
||||
},
|
||||
},
|
||||
features: {},
|
||||
featureOverrides: {},
|
||||
validFrom: makeDateWithHourOffset(-2),
|
||||
validTo: makeDateWithHourOffset(1),
|
||||
},
|
||||
{
|
||||
id: MOCK_MAIN_PLAN_ID,
|
||||
|
@ -156,8 +175,8 @@ describe('License', () => {
|
|||
},
|
||||
features: {},
|
||||
featureOverrides: {},
|
||||
validFrom: new Date(),
|
||||
validTo: new Date(),
|
||||
validFrom: makeDateWithHourOffset(-1), // this is the LATEST / newest plan
|
||||
validTo: makeDateWithHourOffset(1),
|
||||
},
|
||||
]);
|
||||
jest.fn(license.getMainPlan).mockReset();
|
||||
|
@ -175,8 +194,8 @@ describe('License', () => {
|
|||
productMetadata: {}, // has no `productMetadata.terms.isMainPlan`!
|
||||
features: {},
|
||||
featureOverrides: {},
|
||||
validFrom: new Date(),
|
||||
validTo: new Date(),
|
||||
validFrom: makeDateWithHourOffset(-1),
|
||||
validTo: makeDateWithHourOffset(1),
|
||||
},
|
||||
{
|
||||
id: 'c1aae471-c24e-4874-ad88-b97107de486c',
|
||||
|
@ -184,8 +203,8 @@ describe('License', () => {
|
|||
productMetadata: {}, // has no `productMetadata.terms.isMainPlan`!
|
||||
features: {},
|
||||
featureOverrides: {},
|
||||
validFrom: new Date(),
|
||||
validTo: new Date(),
|
||||
validFrom: makeDateWithHourOffset(-1),
|
||||
validTo: makeDateWithHourOffset(1),
|
||||
},
|
||||
]);
|
||||
jest.fn(license.getMainPlan).mockReset();
|
||||
|
@ -251,6 +270,20 @@ describe('License', () => {
|
|||
|
||||
expect(LicenseManager).toHaveBeenCalledWith(expect.objectContaining(expectedRenewalSettings));
|
||||
});
|
||||
|
||||
it('when CLI command with N8N_LICENSE_AUTO_RENEW_ENABLED=true, should enable renewal', async () => {
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
license: { ...licenseConfig, autoRenewalEnabled: true },
|
||||
});
|
||||
|
||||
await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init({
|
||||
isCli: true,
|
||||
});
|
||||
|
||||
expect(LicenseManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reinit', () => {
|
||||
|
@ -262,7 +295,7 @@ describe('License', () => {
|
|||
|
||||
await license.reinit();
|
||||
|
||||
expect(initSpy).toHaveBeenCalledWith(true);
|
||||
expect(initSpy).toHaveBeenCalledWith({ forceRecreate: true });
|
||||
|
||||
expect(LicenseManager.prototype.reset).toHaveBeenCalled();
|
||||
expect(LicenseManager.prototype.initialize).toHaveBeenCalled();
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue