mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-02 07:01:30 -08:00
Merge remote-tracking branch 'origin/master' into pay-1745-update-moved-workflow-to-include-option-to-share-credentials
# Conflicts: # packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.test.ts
This commit is contained in:
commit
96f376db54
52
CHANGELOG.md
52
CHANGELOG.md
|
@ -1,3 +1,55 @@
|
||||||
|
# [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)
|
# [1.75.0](https://github.com/n8n-io/n8n/compare/n8n@1.74.0...n8n@1.75.0) (2025-01-15)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -105,7 +105,7 @@ describe('Expression editor modal', () => {
|
||||||
// Run workflow
|
// Run workflow
|
||||||
cy.get('body').type('{esc}');
|
cy.get('body').type('{esc}');
|
||||||
ndv.actions.close();
|
ndv.actions.close();
|
||||||
WorkflowPage.actions.executeNode('No Operation');
|
WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' });
|
||||||
WorkflowPage.actions.openNode('Hacker News');
|
WorkflowPage.actions.openNode('Hacker News');
|
||||||
WorkflowPage.actions.openExpressionEditorModal();
|
WorkflowPage.actions.openExpressionEditorModal();
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "1.75.0",
|
"version": "1.76.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.15",
|
"node": ">=20.15",
|
||||||
|
|
|
@ -27,7 +27,7 @@ docker run ghcr.io/n8n-io/n8n-benchmark:latest run \
|
||||||
--n8nUserPassword=InstanceOwnerPassword \
|
--n8nUserPassword=InstanceOwnerPassword \
|
||||||
--vus=5 \
|
--vus=5 \
|
||||||
--duration=1m \
|
--duration=1m \
|
||||||
--scenarioFilter SingleWebhook
|
--scenarioFilter=single-webhook
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using custom scenarios with the Docker image
|
### Using custom scenarios with the Docker image
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/n8n-benchmark",
|
"name": "@n8n/n8n-benchmark",
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"description": "Cli for running benchmark tests for n8n",
|
"description": "Cli for running benchmark tests for n8n",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -15,7 +15,7 @@ entity { Plaintext | Resolvable }
|
||||||
|
|
||||||
resolvableChar { unicodeChar | "}" ![}] | "\\}}" }
|
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
|
@detectDelim
|
||||||
|
|
|
@ -10,7 +10,7 @@ export const parser = LRParser.deserialize({
|
||||||
skippedNodes: [0],
|
skippedNodes: [0],
|
||||||
repeatNodeCount: 1,
|
repeatNodeCount: 1,
|
||||||
tokenData:
|
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],
|
tokenizers: [0],
|
||||||
topRules: { Program: [0, 1] },
|
topRules: { Program: [0, 1] },
|
||||||
tokenPrec: 0,
|
tokenPrec: 0,
|
||||||
|
|
|
@ -277,3 +277,19 @@ Program(Resolvable)
|
||||||
==>
|
==>
|
||||||
|
|
||||||
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",
|
"name": "@n8n/config",
|
||||||
"version": "1.25.0",
|
"version": "1.26.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
@ -12,7 +12,6 @@ export class TaskRunnersConfig {
|
||||||
@Env('N8N_RUNNERS_ENABLED')
|
@Env('N8N_RUNNERS_ENABLED')
|
||||||
enabled: boolean = false;
|
enabled: boolean = false;
|
||||||
|
|
||||||
// Defaults to true for now
|
|
||||||
@Env('N8N_RUNNERS_MODE')
|
@Env('N8N_RUNNERS_MODE')
|
||||||
mode: TaskRunnerMode = 'internal';
|
mode: TaskRunnerMode = 'internal';
|
||||||
|
|
||||||
|
@ -23,12 +22,12 @@ export class TaskRunnersConfig {
|
||||||
@Env('N8N_RUNNERS_AUTH_TOKEN')
|
@Env('N8N_RUNNERS_AUTH_TOKEN')
|
||||||
authToken: string = '';
|
authToken: string = '';
|
||||||
|
|
||||||
/** IP address task runners server should listen on */
|
/** IP address task runners broker should listen on */
|
||||||
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
|
@Env('N8N_RUNNERS_BROKER_PORT')
|
||||||
port: number = 5679;
|
port: number = 5679;
|
||||||
|
|
||||||
/** IP address task runners server should listen on */
|
/** IP address task runners broker should listen on */
|
||||||
@Env('N8N_RUNNERS_SERVER_LISTEN_ADDRESS')
|
@Env('N8N_RUNNERS_BROKER_LISTEN_ADDRESS')
|
||||||
listenAddress: string = '127.0.0.1';
|
listenAddress: string = '127.0.0.1';
|
||||||
|
|
||||||
/** Maximum size of a payload sent to the runner in bytes, Default 1G */
|
/** Maximum size of a payload sent to the runner in bytes, Default 1G */
|
||||||
|
|
|
@ -147,7 +147,7 @@ export class LmChatOpenAi implements INodeType {
|
||||||
displayName: 'Model',
|
displayName: 'Model',
|
||||||
name: 'model',
|
name: 'model',
|
||||||
type: 'resourceLocator',
|
type: 'resourceLocator',
|
||||||
default: { mode: 'list', value: '' },
|
default: { mode: 'list', value: 'gpt-4o-mini' },
|
||||||
required: true,
|
required: true,
|
||||||
modes: [
|
modes: [
|
||||||
{
|
{
|
||||||
|
@ -164,7 +164,7 @@ export class LmChatOpenAi implements INodeType {
|
||||||
displayName: 'ID',
|
displayName: 'ID',
|
||||||
name: 'id',
|
name: 'id',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
placeholder: '2302163813',
|
placeholder: 'gpt-4o-mini',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
description: 'The model. Choose from the list, or specify an ID.',
|
description: 'The model. Choose from the list, or specify an ID.',
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/n8n-nodes-langchain",
|
"name": "@n8n/n8n-nodes-langchain",
|
||||||
"version": "1.75.0",
|
"version": "1.76.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/task-runner",
|
"name": "@n8n/task-runner",
|
||||||
"version": "1.13.0",
|
"version": "1.14.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"start": "node dist/start.js",
|
"start": "node dist/start.js",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n",
|
"name": "n8n",
|
||||||
"version": "1.75.0",
|
"version": "1.76.0",
|
||||||
"description": "n8n Workflow Automation Tool",
|
"description": "n8n Workflow Automation Tool",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
@ -251,6 +251,20 @@ describe('License', () => {
|
||||||
|
|
||||||
expect(LicenseManager).toHaveBeenCalledWith(expect.objectContaining(expectedRenewalSettings));
|
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', () => {
|
describe('reinit', () => {
|
||||||
|
@ -262,7 +276,7 @@ describe('License', () => {
|
||||||
|
|
||||||
await license.reinit();
|
await license.reinit();
|
||||||
|
|
||||||
expect(initSpy).toHaveBeenCalledWith(true);
|
expect(initSpy).toHaveBeenCalledWith({ forceRecreate: true });
|
||||||
|
|
||||||
expect(LicenseManager.prototype.reset).toHaveBeenCalled();
|
expect(LicenseManager.prototype.reset).toHaveBeenCalled();
|
||||||
expect(LicenseManager.prototype.initialize).toHaveBeenCalled();
|
expect(LicenseManager.prototype.initialize).toHaveBeenCalled();
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { readFile } from 'fs/promises';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
import isbot from 'isbot';
|
import isbot from 'isbot';
|
||||||
import { Logger } from 'n8n-core';
|
import { Logger } from 'n8n-core';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { N8N_VERSION, TEMPLATES_DIR, inDevelopment, inTest } from '@/constants';
|
import { N8N_VERSION, TEMPLATES_DIR, inDevelopment, inTest } from '@/constants';
|
||||||
|
@ -67,6 +68,9 @@ export abstract class AbstractServer {
|
||||||
this.app.set('view engine', 'handlebars');
|
this.app.set('view engine', 'handlebars');
|
||||||
this.app.set('views', TEMPLATES_DIR);
|
this.app.set('views', TEMPLATES_DIR);
|
||||||
|
|
||||||
|
const assetsPath: string = path.join(__dirname, '../../../assets');
|
||||||
|
this.app.use(express.static(assetsPath));
|
||||||
|
|
||||||
const proxyHops = config.getEnv('proxy_hops');
|
const proxyHops = config.getEnv('proxy_hops');
|
||||||
if (proxyHops > 0) this.app.set('trust proxy', proxyHops);
|
if (proxyHops > 0) this.app.set('trust proxy', proxyHops);
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,7 @@ import {
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import { OnShutdown } from '@/decorators/on-shutdown';
|
import { OnShutdown } from '@/decorators/on-shutdown';
|
||||||
|
import { executeErrorWorkflow } from '@/execution-lifecycle/execute-error-workflow';
|
||||||
import { ExecutionService } from '@/executions/execution.service';
|
import { ExecutionService } from '@/executions/execution.service';
|
||||||
import { ExternalHooks } from '@/external-hooks';
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
import type { IWorkflowDb } from '@/interfaces';
|
import type { IWorkflowDb } from '@/interfaces';
|
||||||
|
@ -400,7 +401,7 @@ export class ActiveWorkflowManager {
|
||||||
status: 'running',
|
status: 'running',
|
||||||
};
|
};
|
||||||
|
|
||||||
WorkflowExecuteAdditionalData.executeErrorWorkflow(workflowData, fullRunData, mode);
|
executeErrorWorkflow(workflowData, fullRunData, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -16,7 +16,7 @@ export class ClearLicenseCommand extends BaseCommand {
|
||||||
|
|
||||||
// Attempt to invoke shutdown() to force any floating entitlements to be released
|
// Attempt to invoke shutdown() to force any floating entitlements to be released
|
||||||
const license = Container.get(License);
|
const license = Container.get(License);
|
||||||
await license.init();
|
await license.init({ isCli: true });
|
||||||
try {
|
try {
|
||||||
await license.shutdown();
|
await license.shutdown();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -11,7 +11,7 @@ export class LicenseInfoCommand extends BaseCommand {
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
const license = Container.get(License);
|
const license = Container.get(License);
|
||||||
await license.init();
|
await license.init({ isCli: true });
|
||||||
|
|
||||||
this.logger.info('Printing license information:\n' + license.getInfo());
|
this.logger.info('Printing license information:\n' + license.getInfo());
|
||||||
}
|
}
|
||||||
|
|
|
@ -255,7 +255,7 @@ describe('OAuth2CredentialController', () => {
|
||||||
type: 'oAuth2Api',
|
type: 'oAuth2Api',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(res.render).toHaveBeenCalledWith('oauth-callback');
|
expect(res.render).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges oauthTokenData if it already exists', async () => {
|
it('merges oauthTokenData if it already exists', async () => {
|
||||||
|
@ -297,7 +297,7 @@ describe('OAuth2CredentialController', () => {
|
||||||
type: 'oAuth2Api',
|
type: 'oAuth2Api',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(res.render).toHaveBeenCalledWith('oauth-callback');
|
expect(res.render).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overwrites oauthTokenData if it is a string', async () => {
|
it('overwrites oauthTokenData if it is a string', async () => {
|
||||||
|
@ -335,7 +335,7 @@ describe('OAuth2CredentialController', () => {
|
||||||
type: 'oAuth2Api',
|
type: 'oAuth2Api',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(res.render).toHaveBeenCalledWith('oauth-callback');
|
expect(res.render).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -149,7 +149,7 @@ export class OAuth2CredentialController extends AbstractOAuthController {
|
||||||
credentialId: credential.id,
|
credentialId: credential.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.render('oauth-callback');
|
return res.render('oauth-callback', { imagePath: 'n8n-logo.png' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.renderCallbackError(
|
return this.renderCallbackError(
|
||||||
res,
|
res,
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
import type { RelayEventMap } from '@/events/maps/relay.event-map';
|
import type { RelayEventMap } from '@/events/maps/relay.event-map';
|
||||||
import { determineFinalExecutionStatus } from '@/execution-lifecycle-hooks/shared/shared-hook-functions';
|
import { determineFinalExecutionStatus } from '@/execution-lifecycle/shared/shared-hook-functions';
|
||||||
import type { IExecutionTrackProperties } from '@/interfaces';
|
import type { IExecutionTrackProperties } from '@/interfaces';
|
||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
|
|
|
@ -26,8 +26,9 @@ import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getWorkflowHooksMain,
|
getWorkflowHooksMain,
|
||||||
|
getWorkflowHooksWorkerExecuter,
|
||||||
getWorkflowHooksWorkerMain,
|
getWorkflowHooksWorkerMain,
|
||||||
} from '../workflow-execute-additional-data';
|
} from '../execution-lifecycle-hooks';
|
||||||
|
|
||||||
describe('Execution Lifecycle Hooks', () => {
|
describe('Execution Lifecycle Hooks', () => {
|
||||||
mockInstance(Logger);
|
mockInstance(Logger);
|
||||||
|
@ -532,4 +533,85 @@ describe('Execution Lifecycle Hooks', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getWorkflowHooksWorkerExecuter', () => {
|
||||||
|
let hooks: WorkflowHooks;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hooks = getWorkflowHooksWorkerExecuter(executionMode, executionId, workflowData, {
|
||||||
|
pushRef,
|
||||||
|
retryOf,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saving static data', () => {
|
||||||
|
it('should skip saving static data for manual executions', async () => {
|
||||||
|
hooks.mode = 'manual';
|
||||||
|
|
||||||
|
await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]);
|
||||||
|
|
||||||
|
expect(workflowStaticDataService.saveStaticDataById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save static data for prod executions', async () => {
|
||||||
|
hooks.mode = 'trigger';
|
||||||
|
|
||||||
|
await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]);
|
||||||
|
|
||||||
|
expect(workflowStaticDataService.saveStaticDataById).toHaveBeenCalledWith(
|
||||||
|
workflowId,
|
||||||
|
staticData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle static data saving errors', async () => {
|
||||||
|
hooks.mode = 'trigger';
|
||||||
|
const error = new Error('Static data save failed');
|
||||||
|
workflowStaticDataService.saveStaticDataById.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]);
|
||||||
|
|
||||||
|
expect(errorReporter.error).toHaveBeenCalledWith(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error workflow', () => {
|
||||||
|
it('should not execute error workflow for manual executions', async () => {
|
||||||
|
hooks.mode = 'manual';
|
||||||
|
|
||||||
|
await hooks.executeHookFunctions('workflowExecuteAfter', [failedRun, {}]);
|
||||||
|
|
||||||
|
expect(workflowExecutionService.executeErrorWorkflow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute error workflow for failed non-manual executions', async () => {
|
||||||
|
hooks.mode = 'trigger';
|
||||||
|
const errorWorkflow = 'error-workflow-id';
|
||||||
|
workflowData.settings = { errorWorkflow };
|
||||||
|
const project = mock<Project>();
|
||||||
|
ownershipService.getWorkflowProjectCached.calledWith(workflowId).mockResolvedValue(project);
|
||||||
|
|
||||||
|
await hooks.executeHookFunctions('workflowExecuteAfter', [failedRun, {}]);
|
||||||
|
|
||||||
|
expect(workflowExecutionService.executeErrorWorkflow).toHaveBeenCalledWith(
|
||||||
|
errorWorkflow,
|
||||||
|
{
|
||||||
|
workflow: {
|
||||||
|
id: workflowId,
|
||||||
|
name: workflowData.name,
|
||||||
|
},
|
||||||
|
execution: {
|
||||||
|
id: executionId,
|
||||||
|
error: expressionError,
|
||||||
|
mode: 'trigger',
|
||||||
|
retryOf,
|
||||||
|
lastNodeExecuted: undefined,
|
||||||
|
url: `http://localhost:5678/workflow/${workflowId}/executions/${executionId}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
|
@ -2,7 +2,7 @@ import { BinaryDataService } from 'n8n-core';
|
||||||
import type { IRun } from 'n8n-workflow';
|
import type { IRun } from 'n8n-workflow';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { restoreBinaryDataId } from '@/execution-lifecycle-hooks/restore-binary-data-id';
|
import { restoreBinaryDataId } from '@/execution-lifecycle/restore-binary-data-id';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
function toIRun(item?: object) {
|
function toIRun(item?: object) {
|
|
@ -3,11 +3,12 @@ import { Logger } from 'n8n-core';
|
||||||
import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow';
|
import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow';
|
||||||
|
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
import { saveExecutionProgress } from '@/execution-lifecycle-hooks/save-execution-progress';
|
|
||||||
import * as fnModule from '@/execution-lifecycle-hooks/to-save-settings';
|
|
||||||
import type { IExecutionResponse } from '@/interfaces';
|
import type { IExecutionResponse } from '@/interfaces';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
|
import { saveExecutionProgress } from '../save-execution-progress';
|
||||||
|
import * as fnModule from '../to-save-settings';
|
||||||
|
|
||||||
mockInstance(Logger);
|
mockInstance(Logger);
|
||||||
const errorReporter = mockInstance(ErrorReporter);
|
const errorReporter = mockInstance(ErrorReporter);
|
||||||
const executionRepository = mockInstance(ExecutionRepository);
|
const executionRepository = mockInstance(ExecutionRepository);
|
|
@ -1,5 +1,6 @@
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { toSaveSettings } from '@/execution-lifecycle-hooks/to-save-settings';
|
|
||||||
|
import { toSaveSettings } from '../to-save-settings';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
config.load(config.default);
|
config.load(config.default);
|
130
packages/cli/src/execution-lifecycle/execute-error-workflow.ts
Normal file
130
packages/cli/src/execution-lifecycle/execute-error-workflow.ts
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
import { GlobalConfig } from '@n8n/config';
|
||||||
|
import { Container } from '@n8n/di';
|
||||||
|
import { ErrorReporter, Logger } from 'n8n-core';
|
||||||
|
import type { IRun, IWorkflowBase, WorkflowExecuteMode } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type { IWorkflowErrorData } from '@/interfaces';
|
||||||
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
|
import { UrlService } from '@/services/url.service';
|
||||||
|
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there was an error and if errorWorkflow or a trigger is defined. If so it collects
|
||||||
|
* all the data and executes it
|
||||||
|
*
|
||||||
|
* @param {IWorkflowBase} workflowData The workflow which got executed
|
||||||
|
* @param {IRun} fullRunData The run which produced the error
|
||||||
|
* @param {WorkflowExecuteMode} mode The mode in which the workflow got started in
|
||||||
|
* @param {string} [executionId] The id the execution got saved as
|
||||||
|
*/
|
||||||
|
export function executeErrorWorkflow(
|
||||||
|
workflowData: IWorkflowBase,
|
||||||
|
fullRunData: IRun,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
executionId?: string,
|
||||||
|
retryOf?: string,
|
||||||
|
): void {
|
||||||
|
const logger = Container.get(Logger);
|
||||||
|
|
||||||
|
// Check if there was an error and if so if an errorWorkflow or a trigger is set
|
||||||
|
let pastExecutionUrl: string | undefined;
|
||||||
|
if (executionId !== undefined) {
|
||||||
|
pastExecutionUrl = `${Container.get(UrlService).getWebhookBaseUrl()}workflow/${
|
||||||
|
workflowData.id
|
||||||
|
}/executions/${executionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullRunData.data.resultData.error !== undefined) {
|
||||||
|
let workflowErrorData: IWorkflowErrorData;
|
||||||
|
const workflowId = workflowData.id;
|
||||||
|
|
||||||
|
if (executionId) {
|
||||||
|
// The error did happen in an execution
|
||||||
|
workflowErrorData = {
|
||||||
|
execution: {
|
||||||
|
id: executionId,
|
||||||
|
url: pastExecutionUrl,
|
||||||
|
error: fullRunData.data.resultData.error,
|
||||||
|
lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!,
|
||||||
|
mode,
|
||||||
|
retryOf,
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
id: workflowId,
|
||||||
|
name: workflowData.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// The error did happen in a trigger
|
||||||
|
workflowErrorData = {
|
||||||
|
trigger: {
|
||||||
|
error: fullRunData.data.resultData.error,
|
||||||
|
mode,
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
id: workflowId,
|
||||||
|
name: workflowData.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { errorTriggerType } = Container.get(GlobalConfig).nodes;
|
||||||
|
// Run the error workflow
|
||||||
|
// To avoid an infinite loop do not run the error workflow again if the error-workflow itself failed and it is its own error-workflow.
|
||||||
|
const { errorWorkflow } = workflowData.settings ?? {};
|
||||||
|
if (errorWorkflow && !(mode === 'error' && workflowId && errorWorkflow === workflowId)) {
|
||||||
|
logger.debug('Start external error workflow', {
|
||||||
|
executionId,
|
||||||
|
errorWorkflowId: errorWorkflow,
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
// If a specific error workflow is set run only that one
|
||||||
|
|
||||||
|
// First, do permission checks.
|
||||||
|
if (!workflowId) {
|
||||||
|
// Manual executions do not trigger error workflows
|
||||||
|
// So this if should never happen. It was added to
|
||||||
|
// make sure there are no possible security gaps
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Container.get(OwnershipService)
|
||||||
|
.getWorkflowProjectCached(workflowId)
|
||||||
|
.then((project) => {
|
||||||
|
void Container.get(WorkflowExecutionService).executeErrorWorkflow(
|
||||||
|
errorWorkflow,
|
||||||
|
workflowErrorData,
|
||||||
|
project,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
Container.get(ErrorReporter).error(error);
|
||||||
|
logger.error(
|
||||||
|
`Could not execute ErrorWorkflow for execution ID ${executionId} because of error querying the workflow owner`,
|
||||||
|
{
|
||||||
|
executionId,
|
||||||
|
errorWorkflowId: errorWorkflow,
|
||||||
|
workflowId,
|
||||||
|
error,
|
||||||
|
workflowErrorData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
mode !== 'error' &&
|
||||||
|
workflowId !== undefined &&
|
||||||
|
workflowData.nodes.some((node) => node.type === errorTriggerType)
|
||||||
|
) {
|
||||||
|
logger.debug('Start internal error workflow', { executionId, workflowId });
|
||||||
|
void Container.get(OwnershipService)
|
||||||
|
.getWorkflowProjectCached(workflowId)
|
||||||
|
.then((project) => {
|
||||||
|
void Container.get(WorkflowExecutionService).executeErrorWorkflow(
|
||||||
|
workflowId,
|
||||||
|
workflowErrorData,
|
||||||
|
project,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,628 @@
|
||||||
|
import { Container } from '@n8n/di';
|
||||||
|
import { stringify } from 'flatted';
|
||||||
|
import { ErrorReporter, Logger, InstanceSettings } from 'n8n-core';
|
||||||
|
import { WorkflowHooks } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
IDataObject,
|
||||||
|
INode,
|
||||||
|
IRun,
|
||||||
|
IRunExecutionData,
|
||||||
|
ITaskData,
|
||||||
|
IWorkflowBase,
|
||||||
|
IWorkflowExecuteHooks,
|
||||||
|
IWorkflowHooksOptionalParameters,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
IWorkflowExecutionDataProcess,
|
||||||
|
Workflow,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
|
import { EventService } from '@/events/event.service';
|
||||||
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
|
import { Push } from '@/push';
|
||||||
|
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
|
||||||
|
import { isWorkflowIdValid } from '@/utils';
|
||||||
|
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
||||||
|
|
||||||
|
import { executeErrorWorkflow } from './execute-error-workflow';
|
||||||
|
import { restoreBinaryDataId } from './restore-binary-data-id';
|
||||||
|
import { saveExecutionProgress } from './save-execution-progress';
|
||||||
|
import {
|
||||||
|
determineFinalExecutionStatus,
|
||||||
|
prepareExecutionDataForDbUpdate,
|
||||||
|
updateExistingExecution,
|
||||||
|
} from './shared/shared-hook-functions';
|
||||||
|
import { toSaveSettings } from './to-save-settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns hook functions to push data to Editor-UI
|
||||||
|
*/
|
||||||
|
function hookFunctionsPush(): IWorkflowExecuteHooks {
|
||||||
|
const logger = Container.get(Logger);
|
||||||
|
const pushInstance = Container.get(Push);
|
||||||
|
return {
|
||||||
|
nodeExecuteBefore: [
|
||||||
|
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
||||||
|
const { pushRef, executionId } = this;
|
||||||
|
// Push data to session which started workflow before each
|
||||||
|
// node which starts rendering
|
||||||
|
if (pushRef === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, {
|
||||||
|
executionId,
|
||||||
|
pushRef,
|
||||||
|
workflowId: this.workflowData.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
pushInstance.send({ type: 'nodeExecuteBefore', data: { executionId, nodeName } }, pushRef);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nodeExecuteAfter: [
|
||||||
|
async function (this: WorkflowHooks, nodeName: string, data: ITaskData): Promise<void> {
|
||||||
|
const { pushRef, executionId } = this;
|
||||||
|
// Push data to session which started workflow after each rendered node
|
||||||
|
if (pushRef === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, {
|
||||||
|
executionId,
|
||||||
|
pushRef,
|
||||||
|
workflowId: this.workflowData.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
pushInstance.send(
|
||||||
|
{ type: 'nodeExecuteAfter', data: { executionId, nodeName, data } },
|
||||||
|
pushRef,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workflowExecuteBefore: [
|
||||||
|
async function (this: WorkflowHooks, _workflow, data): Promise<void> {
|
||||||
|
const { pushRef, executionId } = this;
|
||||||
|
const { id: workflowId, name: workflowName } = this.workflowData;
|
||||||
|
logger.debug('Executing hook (hookFunctionsPush)', {
|
||||||
|
executionId,
|
||||||
|
pushRef,
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
// Push data to session which started the workflow
|
||||||
|
if (pushRef === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushInstance.send(
|
||||||
|
{
|
||||||
|
type: 'executionStarted',
|
||||||
|
data: {
|
||||||
|
executionId,
|
||||||
|
mode: this.mode,
|
||||||
|
startedAt: new Date(),
|
||||||
|
retryOf: this.retryOf,
|
||||||
|
workflowId,
|
||||||
|
workflowName,
|
||||||
|
flattedRunData: data?.resultData.runData
|
||||||
|
? stringify(data.resultData.runData)
|
||||||
|
: stringify({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pushRef,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workflowExecuteAfter: [
|
||||||
|
async function (this: WorkflowHooks, fullRunData: IRun): Promise<void> {
|
||||||
|
const { pushRef, executionId } = this;
|
||||||
|
if (pushRef === undefined) return;
|
||||||
|
|
||||||
|
const { id: workflowId } = this.workflowData;
|
||||||
|
logger.debug('Executing hook (hookFunctionsPush)', {
|
||||||
|
executionId,
|
||||||
|
pushRef,
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status } = fullRunData;
|
||||||
|
if (status === 'waiting') {
|
||||||
|
pushInstance.send({ type: 'executionWaiting', data: { executionId } }, pushRef);
|
||||||
|
} else {
|
||||||
|
const rawData = stringify(fullRunData.data);
|
||||||
|
pushInstance.send(
|
||||||
|
{ type: 'executionFinished', data: { executionId, workflowId, status, rawData } },
|
||||||
|
pushRef,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hookFunctionsPreExecute(): IWorkflowExecuteHooks {
|
||||||
|
const externalHooks = Container.get(ExternalHooks);
|
||||||
|
return {
|
||||||
|
workflowExecuteBefore: [
|
||||||
|
async function (this: WorkflowHooks, workflow: Workflow): Promise<void> {
|
||||||
|
await externalHooks.run('workflow.preExecute', [workflow, this.mode]);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nodeExecuteAfter: [
|
||||||
|
async function (
|
||||||
|
this: WorkflowHooks,
|
||||||
|
nodeName: string,
|
||||||
|
data: ITaskData,
|
||||||
|
executionData: IRunExecutionData,
|
||||||
|
): Promise<void> {
|
||||||
|
await saveExecutionProgress(
|
||||||
|
this.workflowData,
|
||||||
|
this.executionId,
|
||||||
|
nodeName,
|
||||||
|
data,
|
||||||
|
executionData,
|
||||||
|
this.pushRef,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns hook functions to save workflow execution and call error workflow
|
||||||
|
*/
|
||||||
|
function hookFunctionsSave(): IWorkflowExecuteHooks {
|
||||||
|
const logger = Container.get(Logger);
|
||||||
|
const workflowStatisticsService = Container.get(WorkflowStatisticsService);
|
||||||
|
const eventService = Container.get(EventService);
|
||||||
|
return {
|
||||||
|
nodeExecuteBefore: [
|
||||||
|
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
||||||
|
const { executionId, workflowData: workflow } = this;
|
||||||
|
|
||||||
|
eventService.emit('node-pre-execute', { executionId, workflow, nodeName });
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nodeExecuteAfter: [
|
||||||
|
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
||||||
|
const { executionId, workflowData: workflow } = this;
|
||||||
|
|
||||||
|
eventService.emit('node-post-execute', { executionId, workflow, nodeName });
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workflowExecuteBefore: [],
|
||||||
|
workflowExecuteAfter: [
|
||||||
|
async function (
|
||||||
|
this: WorkflowHooks,
|
||||||
|
fullRunData: IRun,
|
||||||
|
newStaticData: IDataObject,
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug('Executing hook (hookFunctionsSave)', {
|
||||||
|
executionId: this.executionId,
|
||||||
|
workflowId: this.workflowData.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await restoreBinaryDataId(fullRunData, this.executionId, this.mode);
|
||||||
|
|
||||||
|
const isManualMode = this.mode === 'manual';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) {
|
||||||
|
// Workflow is saved so update in database
|
||||||
|
try {
|
||||||
|
await Container.get(WorkflowStaticDataService).saveStaticDataById(
|
||||||
|
this.workflowData.id,
|
||||||
|
newStaticData,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
Container.get(ErrorReporter).error(e);
|
||||||
|
logger.error(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
`There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (hookFunctionsSave)`,
|
||||||
|
{ executionId: this.executionId, workflowId: this.workflowData.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionStatus = determineFinalExecutionStatus(fullRunData);
|
||||||
|
fullRunData.status = executionStatus;
|
||||||
|
|
||||||
|
const saveSettings = toSaveSettings(this.workflowData.settings);
|
||||||
|
|
||||||
|
if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) {
|
||||||
|
/**
|
||||||
|
* When manual executions are not being saved, we only soft-delete
|
||||||
|
* the execution so that the user can access its binary data
|
||||||
|
* while building their workflow.
|
||||||
|
*
|
||||||
|
* The manual execution and its binary data will be hard-deleted
|
||||||
|
* on the next pruning cycle after the grace period set by
|
||||||
|
* `EXECUTIONS_DATA_HARD_DELETE_BUFFER`.
|
||||||
|
*/
|
||||||
|
await Container.get(ExecutionRepository).softDelete(this.executionId);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldNotSave =
|
||||||
|
(executionStatus === 'success' && !saveSettings.success) ||
|
||||||
|
(executionStatus !== 'success' && !saveSettings.error);
|
||||||
|
|
||||||
|
if (shouldNotSave && !fullRunData.waitTill && !isManualMode) {
|
||||||
|
executeErrorWorkflow(
|
||||||
|
this.workflowData,
|
||||||
|
fullRunData,
|
||||||
|
this.mode,
|
||||||
|
this.executionId,
|
||||||
|
this.retryOf,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Container.get(ExecutionRepository).hardDelete({
|
||||||
|
workflowId: this.workflowData.id,
|
||||||
|
executionId: this.executionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive
|
||||||
|
// As a result, we should create an IWorkflowBase object with only the data we want to save in it.
|
||||||
|
const fullExecutionData = prepareExecutionDataForDbUpdate({
|
||||||
|
runData: fullRunData,
|
||||||
|
workflowData: this.workflowData,
|
||||||
|
workflowStatusFinal: executionStatus,
|
||||||
|
retryOf: this.retryOf,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When going into the waiting state, store the pushRef in the execution-data
|
||||||
|
if (fullRunData.waitTill && isManualMode) {
|
||||||
|
fullExecutionData.data.pushRef = this.pushRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateExistingExecution({
|
||||||
|
executionId: this.executionId,
|
||||||
|
workflowId: this.workflowData.id,
|
||||||
|
executionData: fullExecutionData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isManualMode) {
|
||||||
|
executeErrorWorkflow(
|
||||||
|
this.workflowData,
|
||||||
|
fullRunData,
|
||||||
|
this.mode,
|
||||||
|
this.executionId,
|
||||||
|
this.retryOf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Container.get(ErrorReporter).error(error);
|
||||||
|
logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, {
|
||||||
|
executionId: this.executionId,
|
||||||
|
workflowId: this.workflowData.id,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
if (!isManualMode) {
|
||||||
|
executeErrorWorkflow(
|
||||||
|
this.workflowData,
|
||||||
|
fullRunData,
|
||||||
|
this.mode,
|
||||||
|
this.executionId,
|
||||||
|
this.retryOf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
workflowStatisticsService.emit('workflowExecutionCompleted', {
|
||||||
|
workflowData: this.workflowData,
|
||||||
|
fullRunData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nodeFetchedData: [
|
||||||
|
async (workflowId: string, node: INode) => {
|
||||||
|
workflowStatisticsService.emit('nodeFetchedData', { workflowId, node });
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns hook functions to save workflow execution and call error workflow
|
||||||
|
* for running with queues. Manual executions should never run on queues as
|
||||||
|
* they are always executed in the main process.
|
||||||
|
*/
|
||||||
|
function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
|
||||||
|
const logger = Container.get(Logger);
|
||||||
|
const workflowStatisticsService = Container.get(WorkflowStatisticsService);
|
||||||
|
const eventService = Container.get(EventService);
|
||||||
|
return {
|
||||||
|
nodeExecuteBefore: [
|
||||||
|
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
||||||
|
const { executionId, workflowData: workflow } = this;
|
||||||
|
|
||||||
|
eventService.emit('node-pre-execute', { executionId, workflow, nodeName });
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nodeExecuteAfter: [
|
||||||
|
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
||||||
|
const { executionId, workflowData: workflow } = this;
|
||||||
|
|
||||||
|
eventService.emit('node-post-execute', { executionId, workflow, nodeName });
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workflowExecuteBefore: [
|
||||||
|
async function (this: WorkflowHooks): Promise<void> {
|
||||||
|
const { executionId, workflowData } = this;
|
||||||
|
|
||||||
|
eventService.emit('workflow-pre-execute', { executionId, data: workflowData });
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workflowExecuteAfter: [
|
||||||
|
async function (
|
||||||
|
this: WorkflowHooks,
|
||||||
|
fullRunData: IRun,
|
||||||
|
newStaticData: IDataObject,
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug('Executing hook (hookFunctionsSaveWorker)', {
|
||||||
|
executionId: this.executionId,
|
||||||
|
workflowId: this.workflowData.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isManualMode = this.mode === 'manual';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) {
|
||||||
|
// Workflow is saved so update in database
|
||||||
|
try {
|
||||||
|
await Container.get(WorkflowStaticDataService).saveStaticDataById(
|
||||||
|
this.workflowData.id,
|
||||||
|
newStaticData,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
Container.get(ErrorReporter).error(e);
|
||||||
|
logger.error(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
`There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (workflowExecuteAfter)`,
|
||||||
|
{ pushRef: this.pushRef, workflowId: this.workflowData.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowStatusFinal = determineFinalExecutionStatus(fullRunData);
|
||||||
|
fullRunData.status = workflowStatusFinal;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isManualMode &&
|
||||||
|
workflowStatusFinal !== 'success' &&
|
||||||
|
workflowStatusFinal !== 'waiting'
|
||||||
|
) {
|
||||||
|
executeErrorWorkflow(
|
||||||
|
this.workflowData,
|
||||||
|
fullRunData,
|
||||||
|
this.mode,
|
||||||
|
this.executionId,
|
||||||
|
this.retryOf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive
|
||||||
|
// As a result, we should create an IWorkflowBase object with only the data we want to save in it.
|
||||||
|
const fullExecutionData = prepareExecutionDataForDbUpdate({
|
||||||
|
runData: fullRunData,
|
||||||
|
workflowData: this.workflowData,
|
||||||
|
workflowStatusFinal,
|
||||||
|
retryOf: this.retryOf,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When going into the waiting state, store the pushRef in the execution-data
|
||||||
|
if (fullRunData.waitTill && isManualMode) {
|
||||||
|
fullExecutionData.data.pushRef = this.pushRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateExistingExecution({
|
||||||
|
executionId: this.executionId,
|
||||||
|
workflowId: this.workflowData.id,
|
||||||
|
executionData: fullExecutionData,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isManualMode) {
|
||||||
|
executeErrorWorkflow(
|
||||||
|
this.workflowData,
|
||||||
|
fullRunData,
|
||||||
|
this.mode,
|
||||||
|
this.executionId,
|
||||||
|
this.retryOf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
workflowStatisticsService.emit('workflowExecutionCompleted', {
|
||||||
|
workflowData: this.workflowData,
|
||||||
|
fullRunData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async function (this: WorkflowHooks, runData: IRun): Promise<void> {
|
||||||
|
const { executionId, workflowData: workflow } = this;
|
||||||
|
|
||||||
|
eventService.emit('workflow-post-execute', {
|
||||||
|
workflow,
|
||||||
|
executionId,
|
||||||
|
runData,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async function (this: WorkflowHooks, fullRunData: IRun) {
|
||||||
|
const externalHooks = Container.get(ExternalHooks);
|
||||||
|
if (externalHooks.exists('workflow.postExecute')) {
|
||||||
|
try {
|
||||||
|
await externalHooks.run('workflow.postExecute', [
|
||||||
|
fullRunData,
|
||||||
|
this.workflowData,
|
||||||
|
this.executionId,
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
Container.get(ErrorReporter).error(error);
|
||||||
|
Container.get(Logger).error(
|
||||||
|
'There was a problem running hook "workflow.postExecute"',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nodeFetchedData: [
|
||||||
|
async (workflowId: string, node: INode) => {
|
||||||
|
workflowStatisticsService.emit('nodeFetchedData', { workflowId, node });
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns WorkflowHooks instance for running integrated workflows
|
||||||
|
* (Workflows which get started inside of another workflow)
|
||||||
|
*/
|
||||||
|
export function getWorkflowHooksIntegrated(
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
executionId: string,
|
||||||
|
workflowData: IWorkflowBase,
|
||||||
|
): WorkflowHooks {
|
||||||
|
const hookFunctions = hookFunctionsSave();
|
||||||
|
const preExecuteFunctions = hookFunctionsPreExecute();
|
||||||
|
for (const key of Object.keys(preExecuteFunctions)) {
|
||||||
|
const hooks = hookFunctions[key] ?? [];
|
||||||
|
hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]);
|
||||||
|
}
|
||||||
|
return new WorkflowHooks(hookFunctions, mode, executionId, workflowData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns WorkflowHooks instance for worker in scaling mode.
|
||||||
|
*/
|
||||||
|
export function getWorkflowHooksWorkerExecuter(
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
executionId: string,
|
||||||
|
workflowData: IWorkflowBase,
|
||||||
|
optionalParameters?: IWorkflowHooksOptionalParameters,
|
||||||
|
): WorkflowHooks {
|
||||||
|
optionalParameters = optionalParameters || {};
|
||||||
|
const hookFunctions = hookFunctionsSaveWorker();
|
||||||
|
const preExecuteFunctions = hookFunctionsPreExecute();
|
||||||
|
for (const key of Object.keys(preExecuteFunctions)) {
|
||||||
|
const hooks = hookFunctions[key] ?? [];
|
||||||
|
hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'manual' && Container.get(InstanceSettings).isWorker) {
|
||||||
|
const pushHooks = hookFunctionsPush();
|
||||||
|
for (const key of Object.keys(pushHooks)) {
|
||||||
|
if (hookFunctions[key] === undefined) {
|
||||||
|
hookFunctions[key] = [];
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line prefer-spread
|
||||||
|
hookFunctions[key].push.apply(hookFunctions[key], pushHooks[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns WorkflowHooks instance for main process if workflow runs via worker
|
||||||
|
*/
|
||||||
|
export function getWorkflowHooksWorkerMain(
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
executionId: string,
|
||||||
|
workflowData: IWorkflowBase,
|
||||||
|
optionalParameters?: IWorkflowHooksOptionalParameters,
|
||||||
|
): WorkflowHooks {
|
||||||
|
optionalParameters = optionalParameters || {};
|
||||||
|
const hookFunctions = hookFunctionsPreExecute();
|
||||||
|
|
||||||
|
// TODO: why are workers pushing to frontend?
|
||||||
|
// TODO: simplifying this for now to just leave the bare minimum hooks
|
||||||
|
|
||||||
|
// const hookFunctions = hookFunctionsPush();
|
||||||
|
// const preExecuteFunctions = hookFunctionsPreExecute();
|
||||||
|
// for (const key of Object.keys(preExecuteFunctions)) {
|
||||||
|
// if (hookFunctions[key] === undefined) {
|
||||||
|
// hookFunctions[key] = [];
|
||||||
|
// }
|
||||||
|
// hookFunctions[key]!.push.apply(hookFunctions[key], preExecuteFunctions[key]);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// When running with worker mode, main process executes
|
||||||
|
// Only workflowExecuteBefore + workflowExecuteAfter
|
||||||
|
// So to avoid confusion, we are removing other hooks.
|
||||||
|
hookFunctions.nodeExecuteBefore = [];
|
||||||
|
hookFunctions.nodeExecuteAfter = [];
|
||||||
|
hookFunctions.workflowExecuteAfter = [
|
||||||
|
async function (this: WorkflowHooks, fullRunData: IRun): Promise<void> {
|
||||||
|
// Don't delete executions before they are finished
|
||||||
|
if (!fullRunData.finished) return;
|
||||||
|
|
||||||
|
const executionStatus = determineFinalExecutionStatus(fullRunData);
|
||||||
|
fullRunData.status = executionStatus;
|
||||||
|
|
||||||
|
const saveSettings = toSaveSettings(this.workflowData.settings);
|
||||||
|
|
||||||
|
const isManualMode = this.mode === 'manual';
|
||||||
|
|
||||||
|
if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) {
|
||||||
|
/**
|
||||||
|
* When manual executions are not being saved, we only soft-delete
|
||||||
|
* the execution so that the user can access its binary data
|
||||||
|
* while building their workflow.
|
||||||
|
*
|
||||||
|
* The manual execution and its binary data will be hard-deleted
|
||||||
|
* on the next pruning cycle after the grace period set by
|
||||||
|
* `EXECUTIONS_DATA_HARD_DELETE_BUFFER`.
|
||||||
|
*/
|
||||||
|
await Container.get(ExecutionRepository).softDelete(this.executionId);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldNotSave =
|
||||||
|
(executionStatus === 'success' && !saveSettings.success) ||
|
||||||
|
(executionStatus !== 'success' && !saveSettings.error);
|
||||||
|
|
||||||
|
if (!isManualMode && shouldNotSave && !fullRunData.waitTill) {
|
||||||
|
await Container.get(ExecutionRepository).hardDelete({
|
||||||
|
workflowId: this.workflowData.id,
|
||||||
|
executionId: this.executionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns WorkflowHooks instance for running the main workflow
|
||||||
|
*/
|
||||||
|
export function getWorkflowHooksMain(
|
||||||
|
data: IWorkflowExecutionDataProcess,
|
||||||
|
executionId: string,
|
||||||
|
): WorkflowHooks {
|
||||||
|
const hookFunctions = hookFunctionsSave();
|
||||||
|
const pushFunctions = hookFunctionsPush();
|
||||||
|
for (const key of Object.keys(pushFunctions)) {
|
||||||
|
const hooks = hookFunctions[key] ?? [];
|
||||||
|
hooks.push.apply(hookFunctions[key], pushFunctions[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preExecuteFunctions = hookFunctionsPreExecute();
|
||||||
|
for (const key of Object.keys(preExecuteFunctions)) {
|
||||||
|
const hooks = hookFunctions[key] ?? [];
|
||||||
|
hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hookFunctions.nodeExecuteBefore) hookFunctions.nodeExecuteBefore = [];
|
||||||
|
if (!hookFunctions.nodeExecuteAfter) hookFunctions.nodeExecuteAfter = [];
|
||||||
|
|
||||||
|
return new WorkflowHooks(hookFunctions, data.executionMode, executionId, data.workflowData, {
|
||||||
|
pushRef: data.pushRef,
|
||||||
|
retryOf: data.retryOf as string,
|
||||||
|
});
|
||||||
|
}
|
|
@ -3,7 +3,8 @@ import { ErrorReporter, Logger } from 'n8n-core';
|
||||||
import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow';
|
import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow';
|
||||||
|
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
import { toSaveSettings } from '@/execution-lifecycle-hooks/to-save-settings';
|
|
||||||
|
import { toSaveSettings } from './to-save-settings';
|
||||||
|
|
||||||
export async function saveExecutionProgress(
|
export async function saveExecutionProgress(
|
||||||
workflowData: IWorkflowBase,
|
workflowData: IWorkflowBase,
|
|
@ -3,6 +3,7 @@ import { stringify } from 'flatted';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import { InstanceSettings } from 'n8n-core';
|
import { InstanceSettings } from 'n8n-core';
|
||||||
import { randomInt } from 'n8n-workflow';
|
import { randomInt } from 'n8n-workflow';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
|
||||||
import { ARTIFICIAL_TASK_DATA } from '@/constants';
|
import { ARTIFICIAL_TASK_DATA } from '@/constants';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
|
@ -127,12 +128,15 @@ describe('ExecutionRecoveryService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('if leader, with 1+ messages', () => {
|
describe('if leader, with 1+ messages', () => {
|
||||||
test('should return `null` if execution succeeded', async () => {
|
test('for successful dataful execution, should return `null`', async () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
const workflow = await createWorkflow();
|
const workflow = await createWorkflow();
|
||||||
const execution = await createExecution({ status: 'success' }, workflow);
|
const execution = await createExecution(
|
||||||
|
{ status: 'success', data: stringify({ runData: { foo: 'bar' } }) },
|
||||||
|
workflow,
|
||||||
|
);
|
||||||
const messages = setupMessages(execution.id, 'Some workflow');
|
const messages = setupMessages(execution.id, 'Some workflow');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -170,7 +174,38 @@ describe('ExecutionRecoveryService', () => {
|
||||||
expect(amendedExecution).toBeNull();
|
expect(amendedExecution).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update `status`, `stoppedAt` and `data` if last node did not finish', async () => {
|
test('for successful dataless execution, should update `status`, `stoppedAt` and `data`', async () => {
|
||||||
|
/**
|
||||||
|
* Arrange
|
||||||
|
*/
|
||||||
|
const workflow = await createWorkflow();
|
||||||
|
const execution = await createExecution(
|
||||||
|
{
|
||||||
|
status: 'success',
|
||||||
|
data: stringify(undefined), // saved execution but likely crashed while saving high-volume data
|
||||||
|
},
|
||||||
|
workflow,
|
||||||
|
);
|
||||||
|
const messages = setupMessages(execution.id, 'Some workflow');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Act
|
||||||
|
*/
|
||||||
|
const amendedExecution = await executionRecoveryService.recoverFromLogs(
|
||||||
|
execution.id,
|
||||||
|
messages,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert
|
||||||
|
*/
|
||||||
|
assert(amendedExecution);
|
||||||
|
expect(amendedExecution.stoppedAt).not.toBe(execution.stoppedAt);
|
||||||
|
expect(amendedExecution.data).toEqual({ resultData: { runData: {} } });
|
||||||
|
expect(amendedExecution.status).toBe('crashed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('for running execution, should update `status`, `stoppedAt` and `data` if last node did not finish', async () => {
|
||||||
/**
|
/**
|
||||||
* Arrange
|
* Arrange
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -9,9 +9,9 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito
|
||||||
import { NodeCrashedError } from '@/errors/node-crashed.error';
|
import { NodeCrashedError } from '@/errors/node-crashed.error';
|
||||||
import { WorkflowCrashedError } from '@/errors/workflow-crashed.error';
|
import { WorkflowCrashedError } from '@/errors/workflow-crashed.error';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
|
import { getWorkflowHooksMain } from '@/execution-lifecycle/execution-lifecycle-hooks';
|
||||||
import type { IExecutionResponse } from '@/interfaces';
|
import type { IExecutionResponse } from '@/interfaces';
|
||||||
import { Push } from '@/push';
|
import { Push } from '@/push';
|
||||||
import { getWorkflowHooksMain } from '@/workflow-execute-additional-data'; // @TODO: Dependency cycle
|
|
||||||
|
|
||||||
import type { EventMessageTypes } from '../eventbus/event-message-classes';
|
import type { EventMessageTypes } from '../eventbus/event-message-classes';
|
||||||
|
|
||||||
|
@ -73,7 +73,7 @@ export class ExecutionRecoveryService {
|
||||||
unflattenData: true,
|
unflattenData: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!execution || execution.status === 'success') return null;
|
if (!execution || (execution.status === 'success' && execution.data)) return null;
|
||||||
|
|
||||||
const runExecutionData = execution.data ?? { resultData: { runData: {} } };
|
const runExecutionData = execution.data ?? { resultData: { runData: {} } };
|
||||||
|
|
||||||
|
|
|
@ -43,7 +43,10 @@ export class License {
|
||||||
this.logger = this.logger.scoped('license');
|
this.logger = this.logger.scoped('license');
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(forceRecreate = false) {
|
async init({
|
||||||
|
forceRecreate = false,
|
||||||
|
isCli = false,
|
||||||
|
}: { forceRecreate?: boolean; isCli?: boolean } = {}) {
|
||||||
if (this.manager && !forceRecreate) {
|
if (this.manager && !forceRecreate) {
|
||||||
this.logger.warn('License manager already initialized or shutting down');
|
this.logger.warn('License manager already initialized or shutting down');
|
||||||
return;
|
return;
|
||||||
|
@ -73,10 +76,13 @@ export class License {
|
||||||
|
|
||||||
const { isLeader } = this.instanceSettings;
|
const { isLeader } = this.instanceSettings;
|
||||||
const { autoRenewalEnabled } = this.globalConfig.license;
|
const { autoRenewalEnabled } = this.globalConfig.license;
|
||||||
|
const eligibleToRenew = isCli || isLeader;
|
||||||
|
|
||||||
const shouldRenew = isLeader && autoRenewalEnabled;
|
const shouldRenew = eligibleToRenew && autoRenewalEnabled;
|
||||||
|
|
||||||
if (isLeader && !autoRenewalEnabled) this.logger.warn(LICENSE_RENEWAL_DISABLED_WARNING);
|
if (eligibleToRenew && !autoRenewalEnabled) {
|
||||||
|
this.logger.warn(LICENSE_RENEWAL_DISABLED_WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.manager = new LicenseManager({
|
this.manager = new LicenseManager({
|
||||||
|
@ -392,7 +398,7 @@ export class License {
|
||||||
|
|
||||||
async reinit() {
|
async reinit() {
|
||||||
this.manager?.reset();
|
this.manager?.reset();
|
||||||
await this.init(true);
|
await this.init({ forceRecreate: true });
|
||||||
this.logger.debug('License reinitialized');
|
this.logger.debug('License reinitialized');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import type PCancelable from 'p-cancelable';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
import { getWorkflowHooksWorkerExecuter } from '@/execution-lifecycle/execution-lifecycle-hooks';
|
||||||
import { ManualExecutionService } from '@/manual-execution.service';
|
import { ManualExecutionService } from '@/manual-execution.service';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||||
|
@ -124,7 +125,7 @@ export class JobProcessor {
|
||||||
|
|
||||||
const { pushRef } = job.data;
|
const { pushRef } = job.data;
|
||||||
|
|
||||||
additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter(
|
additionalData.hooks = getWorkflowHooksWorkerExecuter(
|
||||||
execution.mode,
|
execution.mode,
|
||||||
job.data.executionId,
|
job.data.executionId,
|
||||||
execution.workflowData,
|
execution.workflowData,
|
||||||
|
|
|
@ -54,6 +54,7 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
|
||||||
|
|
||||||
private readonly passthroughEnvVars = [
|
private readonly passthroughEnvVars = [
|
||||||
'PATH',
|
'PATH',
|
||||||
|
'HOME', // So home directory can be resolved correctly
|
||||||
'GENERIC_TIMEZONE',
|
'GENERIC_TIMEZONE',
|
||||||
'NODE_FUNCTION_ALLOW_BUILTIN',
|
'NODE_FUNCTION_ALLOW_BUILTIN',
|
||||||
'NODE_FUNCTION_ALLOW_EXTERNAL',
|
'NODE_FUNCTION_ALLOW_EXTERNAL',
|
||||||
|
|
|
@ -100,7 +100,7 @@ export class TaskRunnerServer {
|
||||||
this.server.on('error', (error: Error & { code: string }) => {
|
this.server.on('error', (error: Error & { code: string }) => {
|
||||||
if (error.code === 'EADDRINUSE') {
|
if (error.code === 'EADDRINUSE') {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`n8n Task Runner's port ${port} is already in use. Do you have another instance of n8n running already?`,
|
`n8n Task Broker's port ${port} is already in use. Do you have another instance of n8n running already?`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
@ -111,7 +111,7 @@ export class TaskRunnerServer {
|
||||||
this.server.listen(port, address, () => resolve());
|
this.server.listen(port, address, () => resolve());
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.info(`n8n Task Runner server ready on ${address}, port ${port}`);
|
this.logger.info(`n8n Task Broker ready on ${address}, port ${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates WebSocket server for handling upgrade requests */
|
/** Creates WebSocket server for handling upgrade requests */
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { mock } from 'jest-mock-extended';
|
||||||
import type { INode } from 'n8n-workflow';
|
import type { INode } from 'n8n-workflow';
|
||||||
import { NodeOperationError, type Workflow } from 'n8n-workflow';
|
import { NodeOperationError, type Workflow } from 'n8n-workflow';
|
||||||
|
|
||||||
import { objectToError } from '../workflow-execute-additional-data';
|
import { objectToError } from '../object-to-error';
|
||||||
|
|
||||||
describe('objectToError', () => {
|
describe('objectToError', () => {
|
||||||
describe('node error handling', () => {
|
describe('node error handling', () => {
|
53
packages/cli/src/utils/object-to-error.ts
Normal file
53
packages/cli/src/utils/object-to-error.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { isObjectLiteral } from 'n8n-core';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
import type { Workflow } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export function objectToError(errorObject: unknown, workflow: Workflow): Error {
|
||||||
|
// TODO: Expand with other error types
|
||||||
|
if (errorObject instanceof Error) {
|
||||||
|
// If it's already an Error instance, return it as is.
|
||||||
|
return errorObject;
|
||||||
|
} else if (
|
||||||
|
isObjectLiteral(errorObject) &&
|
||||||
|
'message' in errorObject &&
|
||||||
|
typeof errorObject.message === 'string'
|
||||||
|
) {
|
||||||
|
// If it's an object with a 'message' property, create a new Error instance.
|
||||||
|
let error: Error | undefined;
|
||||||
|
if (
|
||||||
|
'node' in errorObject &&
|
||||||
|
isObjectLiteral(errorObject.node) &&
|
||||||
|
typeof errorObject.node.name === 'string'
|
||||||
|
) {
|
||||||
|
const node = workflow.getNode(errorObject.node.name);
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
error = new NodeOperationError(
|
||||||
|
node,
|
||||||
|
errorObject as unknown as Error,
|
||||||
|
errorObject as object,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error === undefined) {
|
||||||
|
error = new Error(errorObject.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('description' in errorObject) {
|
||||||
|
// @ts-expect-error Error descriptions are surfaced by the UI but
|
||||||
|
// not all backend errors account for this property yet.
|
||||||
|
error.description = errorObject.description as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('stack' in errorObject) {
|
||||||
|
// If there's a 'stack' property, set it on the new Error instance.
|
||||||
|
error.stack = errorObject.stack as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error;
|
||||||
|
} else {
|
||||||
|
// If it's neither an Error nor an object with a 'message' property, create a generic Error.
|
||||||
|
return new Error('An error occurred');
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,15 +5,8 @@
|
||||||
import type { PushMessage, PushType } from '@n8n/api-types';
|
import type { PushMessage, PushType } from '@n8n/api-types';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { stringify } from 'flatted';
|
import { Logger, WorkflowExecute } from 'n8n-core';
|
||||||
import {
|
import { ApplicationError, Workflow } from 'n8n-workflow';
|
||||||
ErrorReporter,
|
|
||||||
Logger,
|
|
||||||
InstanceSettings,
|
|
||||||
WorkflowExecute,
|
|
||||||
isObjectLiteral,
|
|
||||||
} from 'n8n-core';
|
|
||||||
import { ApplicationError, NodeOperationError, Workflow, WorkflowHooks } from 'n8n-workflow';
|
|
||||||
import type {
|
import type {
|
||||||
IDataObject,
|
IDataObject,
|
||||||
IExecuteData,
|
IExecuteData,
|
||||||
|
@ -23,11 +16,8 @@ import type {
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
IRun,
|
IRun,
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
ITaskData,
|
|
||||||
IWorkflowBase,
|
IWorkflowBase,
|
||||||
IWorkflowExecuteAdditionalData,
|
IWorkflowExecuteAdditionalData,
|
||||||
IWorkflowExecuteHooks,
|
|
||||||
IWorkflowHooksOptionalParameters,
|
|
||||||
IWorkflowSettings,
|
IWorkflowSettings,
|
||||||
WorkflowExecuteMode,
|
WorkflowExecuteMode,
|
||||||
ExecutionStatus,
|
ExecutionStatus,
|
||||||
|
@ -44,633 +34,23 @@ import type {
|
||||||
import { ActiveExecutions } from '@/active-executions';
|
import { ActiveExecutions } from '@/active-executions';
|
||||||
import { CredentialsHelper } from '@/credentials-helper';
|
import { CredentialsHelper } from '@/credentials-helper';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
import { EventService } from '@/events/event.service';
|
||||||
import type { AiEventMap, AiEventPayload } from '@/events/maps/ai.event-map';
|
import type { AiEventMap, AiEventPayload } from '@/events/maps/ai.event-map';
|
||||||
|
import { getWorkflowHooksIntegrated } from '@/execution-lifecycle/execution-lifecycle-hooks';
|
||||||
import { ExternalHooks } from '@/external-hooks';
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
import type { IWorkflowErrorData, UpdateExecutionPayload } from '@/interfaces';
|
import type { UpdateExecutionPayload } from '@/interfaces';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
import { Push } from '@/push';
|
import { Push } from '@/push';
|
||||||
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
|
import { SecretsHelper } from '@/secrets-helpers.ee';
|
||||||
import { findSubworkflowStart, isWorkflowIdValid } from '@/utils';
|
import { UrlService } from '@/services/url.service';
|
||||||
|
import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service';
|
||||||
|
import { TaskRequester } from '@/task-runners/task-managers/task-requester';
|
||||||
|
import { PermissionChecker } from '@/user-management/permission-checker';
|
||||||
|
import { findSubworkflowStart } from '@/utils';
|
||||||
|
import { objectToError } from '@/utils/object-to-error';
|
||||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||||
|
|
||||||
import { WorkflowRepository } from './databases/repositories/workflow.repository';
|
|
||||||
import { EventService } from './events/event.service';
|
|
||||||
import { restoreBinaryDataId } from './execution-lifecycle-hooks/restore-binary-data-id';
|
|
||||||
import { saveExecutionProgress } from './execution-lifecycle-hooks/save-execution-progress';
|
|
||||||
import {
|
|
||||||
determineFinalExecutionStatus,
|
|
||||||
prepareExecutionDataForDbUpdate,
|
|
||||||
updateExistingExecution,
|
|
||||||
} from './execution-lifecycle-hooks/shared/shared-hook-functions';
|
|
||||||
import { toSaveSettings } from './execution-lifecycle-hooks/to-save-settings';
|
|
||||||
import { SecretsHelper } from './secrets-helpers.ee';
|
|
||||||
import { OwnershipService } from './services/ownership.service';
|
|
||||||
import { UrlService } from './services/url.service';
|
|
||||||
import { SubworkflowPolicyChecker } from './subworkflows/subworkflow-policy-checker.service';
|
|
||||||
import { TaskRequester } from './task-runners/task-managers/task-requester';
|
|
||||||
import { PermissionChecker } from './user-management/permission-checker';
|
|
||||||
import { WorkflowExecutionService } from './workflows/workflow-execution.service';
|
|
||||||
import { WorkflowStaticDataService } from './workflows/workflow-static-data.service';
|
|
||||||
|
|
||||||
export function objectToError(errorObject: unknown, workflow: Workflow): Error {
|
|
||||||
// TODO: Expand with other error types
|
|
||||||
if (errorObject instanceof Error) {
|
|
||||||
// If it's already an Error instance, return it as is.
|
|
||||||
return errorObject;
|
|
||||||
} else if (
|
|
||||||
isObjectLiteral(errorObject) &&
|
|
||||||
'message' in errorObject &&
|
|
||||||
typeof errorObject.message === 'string'
|
|
||||||
) {
|
|
||||||
// If it's an object with a 'message' property, create a new Error instance.
|
|
||||||
let error: Error | undefined;
|
|
||||||
if (
|
|
||||||
'node' in errorObject &&
|
|
||||||
isObjectLiteral(errorObject.node) &&
|
|
||||||
typeof errorObject.node.name === 'string'
|
|
||||||
) {
|
|
||||||
const node = workflow.getNode(errorObject.node.name);
|
|
||||||
|
|
||||||
if (node) {
|
|
||||||
error = new NodeOperationError(
|
|
||||||
node,
|
|
||||||
errorObject as unknown as Error,
|
|
||||||
errorObject as object,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error === undefined) {
|
|
||||||
error = new Error(errorObject.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('description' in errorObject) {
|
|
||||||
// @ts-expect-error Error descriptions are surfaced by the UI but
|
|
||||||
// not all backend errors account for this property yet.
|
|
||||||
error.description = errorObject.description as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('stack' in errorObject) {
|
|
||||||
// If there's a 'stack' property, set it on the new Error instance.
|
|
||||||
error.stack = errorObject.stack as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
return error;
|
|
||||||
} else {
|
|
||||||
// If it's neither an Error nor an object with a 'message' property, create a generic Error.
|
|
||||||
return new Error('An error occurred');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if there was an error and if errorWorkflow or a trigger is defined. If so it collects
|
|
||||||
* all the data and executes it
|
|
||||||
*
|
|
||||||
* @param {IWorkflowBase} workflowData The workflow which got executed
|
|
||||||
* @param {IRun} fullRunData The run which produced the error
|
|
||||||
* @param {WorkflowExecuteMode} mode The mode in which the workflow got started in
|
|
||||||
* @param {string} [executionId] The id the execution got saved as
|
|
||||||
*/
|
|
||||||
export function executeErrorWorkflow(
|
|
||||||
workflowData: IWorkflowBase,
|
|
||||||
fullRunData: IRun,
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
executionId?: string,
|
|
||||||
retryOf?: string,
|
|
||||||
): void {
|
|
||||||
const logger = Container.get(Logger);
|
|
||||||
|
|
||||||
// Check if there was an error and if so if an errorWorkflow or a trigger is set
|
|
||||||
let pastExecutionUrl: string | undefined;
|
|
||||||
if (executionId !== undefined) {
|
|
||||||
pastExecutionUrl = `${Container.get(UrlService).getWebhookBaseUrl()}workflow/${
|
|
||||||
workflowData.id
|
|
||||||
}/executions/${executionId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fullRunData.data.resultData.error !== undefined) {
|
|
||||||
let workflowErrorData: IWorkflowErrorData;
|
|
||||||
const workflowId = workflowData.id;
|
|
||||||
|
|
||||||
if (executionId) {
|
|
||||||
// The error did happen in an execution
|
|
||||||
workflowErrorData = {
|
|
||||||
execution: {
|
|
||||||
id: executionId,
|
|
||||||
url: pastExecutionUrl,
|
|
||||||
error: fullRunData.data.resultData.error,
|
|
||||||
lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!,
|
|
||||||
mode,
|
|
||||||
retryOf,
|
|
||||||
},
|
|
||||||
workflow: {
|
|
||||||
id: workflowId,
|
|
||||||
name: workflowData.name,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// The error did happen in a trigger
|
|
||||||
workflowErrorData = {
|
|
||||||
trigger: {
|
|
||||||
error: fullRunData.data.resultData.error,
|
|
||||||
mode,
|
|
||||||
},
|
|
||||||
workflow: {
|
|
||||||
id: workflowId,
|
|
||||||
name: workflowData.name,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { errorTriggerType } = Container.get(GlobalConfig).nodes;
|
|
||||||
// Run the error workflow
|
|
||||||
// To avoid an infinite loop do not run the error workflow again if the error-workflow itself failed and it is its own error-workflow.
|
|
||||||
const { errorWorkflow } = workflowData.settings ?? {};
|
|
||||||
if (errorWorkflow && !(mode === 'error' && workflowId && errorWorkflow === workflowId)) {
|
|
||||||
logger.debug('Start external error workflow', {
|
|
||||||
executionId,
|
|
||||||
errorWorkflowId: errorWorkflow,
|
|
||||||
workflowId,
|
|
||||||
});
|
|
||||||
// If a specific error workflow is set run only that one
|
|
||||||
|
|
||||||
// First, do permission checks.
|
|
||||||
if (!workflowId) {
|
|
||||||
// Manual executions do not trigger error workflows
|
|
||||||
// So this if should never happen. It was added to
|
|
||||||
// make sure there are no possible security gaps
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Container.get(OwnershipService)
|
|
||||||
.getWorkflowProjectCached(workflowId)
|
|
||||||
.then((project) => {
|
|
||||||
void Container.get(WorkflowExecutionService).executeErrorWorkflow(
|
|
||||||
errorWorkflow,
|
|
||||||
workflowErrorData,
|
|
||||||
project,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
Container.get(ErrorReporter).error(error);
|
|
||||||
logger.error(
|
|
||||||
`Could not execute ErrorWorkflow for execution ID ${this.executionId} because of error querying the workflow owner`,
|
|
||||||
{
|
|
||||||
executionId,
|
|
||||||
errorWorkflowId: errorWorkflow,
|
|
||||||
workflowId,
|
|
||||||
error,
|
|
||||||
workflowErrorData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
mode !== 'error' &&
|
|
||||||
workflowId !== undefined &&
|
|
||||||
workflowData.nodes.some((node) => node.type === errorTriggerType)
|
|
||||||
) {
|
|
||||||
logger.debug('Start internal error workflow', { executionId, workflowId });
|
|
||||||
void Container.get(OwnershipService)
|
|
||||||
.getWorkflowProjectCached(workflowId)
|
|
||||||
.then((project) => {
|
|
||||||
void Container.get(WorkflowExecutionService).executeErrorWorkflow(
|
|
||||||
workflowId,
|
|
||||||
workflowErrorData,
|
|
||||||
project,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns hook functions to push data to Editor-UI
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
function hookFunctionsPush(): IWorkflowExecuteHooks {
|
|
||||||
const logger = Container.get(Logger);
|
|
||||||
const pushInstance = Container.get(Push);
|
|
||||||
return {
|
|
||||||
nodeExecuteBefore: [
|
|
||||||
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
|
||||||
const { pushRef, executionId } = this;
|
|
||||||
// Push data to session which started workflow before each
|
|
||||||
// node which starts rendering
|
|
||||||
if (pushRef === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, {
|
|
||||||
executionId,
|
|
||||||
pushRef,
|
|
||||||
workflowId: this.workflowData.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
pushInstance.send({ type: 'nodeExecuteBefore', data: { executionId, nodeName } }, pushRef);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nodeExecuteAfter: [
|
|
||||||
async function (this: WorkflowHooks, nodeName: string, data: ITaskData): Promise<void> {
|
|
||||||
const { pushRef, executionId } = this;
|
|
||||||
// Push data to session which started workflow after each rendered node
|
|
||||||
if (pushRef === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, {
|
|
||||||
executionId,
|
|
||||||
pushRef,
|
|
||||||
workflowId: this.workflowData.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
pushInstance.send(
|
|
||||||
{ type: 'nodeExecuteAfter', data: { executionId, nodeName, data } },
|
|
||||||
pushRef,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
workflowExecuteBefore: [
|
|
||||||
async function (this: WorkflowHooks, _workflow, data): Promise<void> {
|
|
||||||
const { pushRef, executionId } = this;
|
|
||||||
const { id: workflowId, name: workflowName } = this.workflowData;
|
|
||||||
logger.debug('Executing hook (hookFunctionsPush)', {
|
|
||||||
executionId,
|
|
||||||
pushRef,
|
|
||||||
workflowId,
|
|
||||||
});
|
|
||||||
// Push data to session which started the workflow
|
|
||||||
if (pushRef === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pushInstance.send(
|
|
||||||
{
|
|
||||||
type: 'executionStarted',
|
|
||||||
data: {
|
|
||||||
executionId,
|
|
||||||
mode: this.mode,
|
|
||||||
startedAt: new Date(),
|
|
||||||
retryOf: this.retryOf,
|
|
||||||
workflowId,
|
|
||||||
workflowName,
|
|
||||||
flattedRunData: data?.resultData.runData
|
|
||||||
? stringify(data.resultData.runData)
|
|
||||||
: stringify({}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pushRef,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
workflowExecuteAfter: [
|
|
||||||
async function (this: WorkflowHooks, fullRunData: IRun): Promise<void> {
|
|
||||||
const { pushRef, executionId } = this;
|
|
||||||
if (pushRef === undefined) return;
|
|
||||||
|
|
||||||
const { id: workflowId } = this.workflowData;
|
|
||||||
logger.debug('Executing hook (hookFunctionsPush)', {
|
|
||||||
executionId,
|
|
||||||
pushRef,
|
|
||||||
workflowId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { status } = fullRunData;
|
|
||||||
if (status === 'waiting') {
|
|
||||||
pushInstance.send({ type: 'executionWaiting', data: { executionId } }, pushRef);
|
|
||||||
} else {
|
|
||||||
const rawData = stringify(fullRunData.data);
|
|
||||||
pushInstance.send(
|
|
||||||
{ type: 'executionFinished', data: { executionId, workflowId, status, rawData } },
|
|
||||||
pushRef,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hookFunctionsPreExecute(): IWorkflowExecuteHooks {
|
|
||||||
const externalHooks = Container.get(ExternalHooks);
|
|
||||||
return {
|
|
||||||
workflowExecuteBefore: [
|
|
||||||
async function (this: WorkflowHooks, workflow: Workflow): Promise<void> {
|
|
||||||
await externalHooks.run('workflow.preExecute', [workflow, this.mode]);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nodeExecuteAfter: [
|
|
||||||
async function (
|
|
||||||
this: WorkflowHooks,
|
|
||||||
nodeName: string,
|
|
||||||
data: ITaskData,
|
|
||||||
executionData: IRunExecutionData,
|
|
||||||
): Promise<void> {
|
|
||||||
await saveExecutionProgress(
|
|
||||||
this.workflowData,
|
|
||||||
this.executionId,
|
|
||||||
nodeName,
|
|
||||||
data,
|
|
||||||
executionData,
|
|
||||||
this.pushRef,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns hook functions to save workflow execution and call error workflow
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
function hookFunctionsSave(): IWorkflowExecuteHooks {
|
|
||||||
const logger = Container.get(Logger);
|
|
||||||
const workflowStatisticsService = Container.get(WorkflowStatisticsService);
|
|
||||||
const eventService = Container.get(EventService);
|
|
||||||
return {
|
|
||||||
nodeExecuteBefore: [
|
|
||||||
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
|
||||||
const { executionId, workflowData: workflow } = this;
|
|
||||||
|
|
||||||
eventService.emit('node-pre-execute', { executionId, workflow, nodeName });
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nodeExecuteAfter: [
|
|
||||||
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
|
||||||
const { executionId, workflowData: workflow } = this;
|
|
||||||
|
|
||||||
eventService.emit('node-post-execute', { executionId, workflow, nodeName });
|
|
||||||
},
|
|
||||||
],
|
|
||||||
workflowExecuteBefore: [],
|
|
||||||
workflowExecuteAfter: [
|
|
||||||
async function (
|
|
||||||
this: WorkflowHooks,
|
|
||||||
fullRunData: IRun,
|
|
||||||
newStaticData: IDataObject,
|
|
||||||
): Promise<void> {
|
|
||||||
logger.debug('Executing hook (hookFunctionsSave)', {
|
|
||||||
executionId: this.executionId,
|
|
||||||
workflowId: this.workflowData.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
await restoreBinaryDataId(fullRunData, this.executionId, this.mode);
|
|
||||||
|
|
||||||
const isManualMode = this.mode === 'manual';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) {
|
|
||||||
// Workflow is saved so update in database
|
|
||||||
try {
|
|
||||||
await Container.get(WorkflowStaticDataService).saveStaticDataById(
|
|
||||||
this.workflowData.id,
|
|
||||||
newStaticData,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
Container.get(ErrorReporter).error(e);
|
|
||||||
logger.error(
|
|
||||||
`There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (hookFunctionsSave)`,
|
|
||||||
{ executionId: this.executionId, workflowId: this.workflowData.id },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const executionStatus = determineFinalExecutionStatus(fullRunData);
|
|
||||||
fullRunData.status = executionStatus;
|
|
||||||
|
|
||||||
const saveSettings = toSaveSettings(this.workflowData.settings);
|
|
||||||
|
|
||||||
if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) {
|
|
||||||
/**
|
|
||||||
* When manual executions are not being saved, we only soft-delete
|
|
||||||
* the execution so that the user can access its binary data
|
|
||||||
* while building their workflow.
|
|
||||||
*
|
|
||||||
* The manual execution and its binary data will be hard-deleted
|
|
||||||
* on the next pruning cycle after the grace period set by
|
|
||||||
* `EXECUTIONS_DATA_HARD_DELETE_BUFFER`.
|
|
||||||
*/
|
|
||||||
await Container.get(ExecutionRepository).softDelete(this.executionId);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldNotSave =
|
|
||||||
(executionStatus === 'success' && !saveSettings.success) ||
|
|
||||||
(executionStatus !== 'success' && !saveSettings.error);
|
|
||||||
|
|
||||||
if (shouldNotSave && !fullRunData.waitTill && !isManualMode) {
|
|
||||||
executeErrorWorkflow(
|
|
||||||
this.workflowData,
|
|
||||||
fullRunData,
|
|
||||||
this.mode,
|
|
||||||
this.executionId,
|
|
||||||
this.retryOf,
|
|
||||||
);
|
|
||||||
|
|
||||||
await Container.get(ExecutionRepository).hardDelete({
|
|
||||||
workflowId: this.workflowData.id,
|
|
||||||
executionId: this.executionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive
|
|
||||||
// As a result, we should create an IWorkflowBase object with only the data we want to save in it.
|
|
||||||
const fullExecutionData = prepareExecutionDataForDbUpdate({
|
|
||||||
runData: fullRunData,
|
|
||||||
workflowData: this.workflowData,
|
|
||||||
workflowStatusFinal: executionStatus,
|
|
||||||
retryOf: this.retryOf,
|
|
||||||
});
|
|
||||||
|
|
||||||
// When going into the waiting state, store the pushRef in the execution-data
|
|
||||||
if (fullRunData.waitTill && isManualMode) {
|
|
||||||
fullExecutionData.data.pushRef = this.pushRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateExistingExecution({
|
|
||||||
executionId: this.executionId,
|
|
||||||
workflowId: this.workflowData.id,
|
|
||||||
executionData: fullExecutionData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isManualMode) {
|
|
||||||
executeErrorWorkflow(
|
|
||||||
this.workflowData,
|
|
||||||
fullRunData,
|
|
||||||
this.mode,
|
|
||||||
this.executionId,
|
|
||||||
this.retryOf,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Container.get(ErrorReporter).error(error);
|
|
||||||
logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, {
|
|
||||||
executionId: this.executionId,
|
|
||||||
workflowId: this.workflowData.id,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
if (!isManualMode) {
|
|
||||||
executeErrorWorkflow(
|
|
||||||
this.workflowData,
|
|
||||||
fullRunData,
|
|
||||||
this.mode,
|
|
||||||
this.executionId,
|
|
||||||
this.retryOf,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
workflowStatisticsService.emit('workflowExecutionCompleted', {
|
|
||||||
workflowData: this.workflowData,
|
|
||||||
fullRunData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nodeFetchedData: [
|
|
||||||
async (workflowId: string, node: INode) => {
|
|
||||||
workflowStatisticsService.emit('nodeFetchedData', { workflowId, node });
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns hook functions to save workflow execution and call error workflow
|
|
||||||
* for running with queues. Manual executions should never run on queues as
|
|
||||||
* they are always executed in the main process.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
|
|
||||||
const logger = Container.get(Logger);
|
|
||||||
const workflowStatisticsService = Container.get(WorkflowStatisticsService);
|
|
||||||
const eventService = Container.get(EventService);
|
|
||||||
return {
|
|
||||||
nodeExecuteBefore: [
|
|
||||||
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
|
||||||
const { executionId, workflowData: workflow } = this;
|
|
||||||
|
|
||||||
eventService.emit('node-pre-execute', { executionId, workflow, nodeName });
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nodeExecuteAfter: [
|
|
||||||
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
|
||||||
const { executionId, workflowData: workflow } = this;
|
|
||||||
|
|
||||||
eventService.emit('node-post-execute', { executionId, workflow, nodeName });
|
|
||||||
},
|
|
||||||
],
|
|
||||||
workflowExecuteBefore: [
|
|
||||||
async function (): Promise<void> {
|
|
||||||
const { executionId, workflowData } = this;
|
|
||||||
|
|
||||||
eventService.emit('workflow-pre-execute', { executionId, data: workflowData });
|
|
||||||
},
|
|
||||||
],
|
|
||||||
workflowExecuteAfter: [
|
|
||||||
async function (
|
|
||||||
this: WorkflowHooks,
|
|
||||||
fullRunData: IRun,
|
|
||||||
newStaticData: IDataObject,
|
|
||||||
): Promise<void> {
|
|
||||||
logger.debug('Executing hook (hookFunctionsSaveWorker)', {
|
|
||||||
executionId: this.executionId,
|
|
||||||
workflowId: this.workflowData.id,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
if (isWorkflowIdValid(this.workflowData.id) && newStaticData) {
|
|
||||||
// Workflow is saved so update in database
|
|
||||||
try {
|
|
||||||
await Container.get(WorkflowStaticDataService).saveStaticDataById(
|
|
||||||
this.workflowData.id,
|
|
||||||
newStaticData,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
Container.get(ErrorReporter).error(e);
|
|
||||||
logger.error(
|
|
||||||
`There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (workflowExecuteAfter)`,
|
|
||||||
{ pushRef: this.pushRef, workflowId: this.workflowData.id },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const workflowStatusFinal = determineFinalExecutionStatus(fullRunData);
|
|
||||||
fullRunData.status = workflowStatusFinal;
|
|
||||||
|
|
||||||
if (workflowStatusFinal !== 'success' && workflowStatusFinal !== 'waiting') {
|
|
||||||
executeErrorWorkflow(
|
|
||||||
this.workflowData,
|
|
||||||
fullRunData,
|
|
||||||
this.mode,
|
|
||||||
this.executionId,
|
|
||||||
this.retryOf,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive
|
|
||||||
// As a result, we should create an IWorkflowBase object with only the data we want to save in it.
|
|
||||||
const fullExecutionData = prepareExecutionDataForDbUpdate({
|
|
||||||
runData: fullRunData,
|
|
||||||
workflowData: this.workflowData,
|
|
||||||
workflowStatusFinal,
|
|
||||||
retryOf: this.retryOf,
|
|
||||||
});
|
|
||||||
|
|
||||||
await updateExistingExecution({
|
|
||||||
executionId: this.executionId,
|
|
||||||
workflowId: this.workflowData.id,
|
|
||||||
executionData: fullExecutionData,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
executeErrorWorkflow(
|
|
||||||
this.workflowData,
|
|
||||||
fullRunData,
|
|
||||||
this.mode,
|
|
||||||
this.executionId,
|
|
||||||
this.retryOf,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
workflowStatisticsService.emit('workflowExecutionCompleted', {
|
|
||||||
workflowData: this.workflowData,
|
|
||||||
fullRunData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async function (this: WorkflowHooks, runData: IRun): Promise<void> {
|
|
||||||
const { executionId, workflowData: workflow } = this;
|
|
||||||
|
|
||||||
eventService.emit('workflow-post-execute', {
|
|
||||||
workflow,
|
|
||||||
executionId,
|
|
||||||
runData,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async function (this: WorkflowHooks, fullRunData: IRun) {
|
|
||||||
const externalHooks = Container.get(ExternalHooks);
|
|
||||||
if (externalHooks.exists('workflow.postExecute')) {
|
|
||||||
try {
|
|
||||||
await externalHooks.run('workflow.postExecute', [
|
|
||||||
fullRunData,
|
|
||||||
this.workflowData,
|
|
||||||
this.executionId,
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
Container.get(ErrorReporter).error(error);
|
|
||||||
Container.get(Logger).error(
|
|
||||||
'There was a problem running hook "workflow.postExecute"',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nodeFetchedData: [
|
|
||||||
async (workflowId: string, node: INode) => {
|
|
||||||
workflowStatisticsService.emit('nodeFetchedData', { workflowId, node });
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRunData(
|
export async function getRunData(
|
||||||
workflowData: IWorkflowBase,
|
workflowData: IWorkflowBase,
|
||||||
inputData?: INodeExecutionData[],
|
inputData?: INodeExecutionData[],
|
||||||
|
@ -1061,154 +441,3 @@ export async function getBase(
|
||||||
eventService.emit(eventName, payload),
|
eventService.emit(eventName, payload),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns WorkflowHooks instance for running integrated workflows
|
|
||||||
* (Workflows which get started inside of another workflow)
|
|
||||||
*/
|
|
||||||
function getWorkflowHooksIntegrated(
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
executionId: string,
|
|
||||||
workflowData: IWorkflowBase,
|
|
||||||
): WorkflowHooks {
|
|
||||||
const hookFunctions = hookFunctionsSave();
|
|
||||||
const preExecuteFunctions = hookFunctionsPreExecute();
|
|
||||||
for (const key of Object.keys(preExecuteFunctions)) {
|
|
||||||
const hooks = hookFunctions[key] ?? [];
|
|
||||||
hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]);
|
|
||||||
}
|
|
||||||
return new WorkflowHooks(hookFunctions, mode, executionId, workflowData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns WorkflowHooks instance for worker in scaling mode.
|
|
||||||
*/
|
|
||||||
export function getWorkflowHooksWorkerExecuter(
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
executionId: string,
|
|
||||||
workflowData: IWorkflowBase,
|
|
||||||
optionalParameters?: IWorkflowHooksOptionalParameters,
|
|
||||||
): WorkflowHooks {
|
|
||||||
optionalParameters = optionalParameters || {};
|
|
||||||
const hookFunctions = hookFunctionsSaveWorker();
|
|
||||||
const preExecuteFunctions = hookFunctionsPreExecute();
|
|
||||||
for (const key of Object.keys(preExecuteFunctions)) {
|
|
||||||
const hooks = hookFunctions[key] ?? [];
|
|
||||||
hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'manual' && Container.get(InstanceSettings).isWorker) {
|
|
||||||
const pushHooks = hookFunctionsPush();
|
|
||||||
for (const key of Object.keys(pushHooks)) {
|
|
||||||
if (hookFunctions[key] === undefined) {
|
|
||||||
hookFunctions[key] = [];
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line prefer-spread
|
|
||||||
hookFunctions[key].push.apply(hookFunctions[key], pushHooks[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns WorkflowHooks instance for main process if workflow runs via worker
|
|
||||||
*/
|
|
||||||
export function getWorkflowHooksWorkerMain(
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
executionId: string,
|
|
||||||
workflowData: IWorkflowBase,
|
|
||||||
optionalParameters?: IWorkflowHooksOptionalParameters,
|
|
||||||
): WorkflowHooks {
|
|
||||||
optionalParameters = optionalParameters || {};
|
|
||||||
const hookFunctions = hookFunctionsPreExecute();
|
|
||||||
|
|
||||||
// TODO: why are workers pushing to frontend?
|
|
||||||
// TODO: simplifying this for now to just leave the bare minimum hooks
|
|
||||||
|
|
||||||
// const hookFunctions = hookFunctionsPush();
|
|
||||||
// const preExecuteFunctions = hookFunctionsPreExecute();
|
|
||||||
// for (const key of Object.keys(preExecuteFunctions)) {
|
|
||||||
// if (hookFunctions[key] === undefined) {
|
|
||||||
// hookFunctions[key] = [];
|
|
||||||
// }
|
|
||||||
// hookFunctions[key]!.push.apply(hookFunctions[key], preExecuteFunctions[key]);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// When running with worker mode, main process executes
|
|
||||||
// Only workflowExecuteBefore + workflowExecuteAfter
|
|
||||||
// So to avoid confusion, we are removing other hooks.
|
|
||||||
hookFunctions.nodeExecuteBefore = [];
|
|
||||||
hookFunctions.nodeExecuteAfter = [];
|
|
||||||
hookFunctions.workflowExecuteAfter = [
|
|
||||||
async function (this: WorkflowHooks, fullRunData: IRun): Promise<void> {
|
|
||||||
// Don't delete executions before they are finished
|
|
||||||
if (!fullRunData.finished) return;
|
|
||||||
|
|
||||||
const executionStatus = determineFinalExecutionStatus(fullRunData);
|
|
||||||
fullRunData.status = executionStatus;
|
|
||||||
|
|
||||||
const saveSettings = toSaveSettings(this.workflowData.settings);
|
|
||||||
|
|
||||||
const isManualMode = this.mode === 'manual';
|
|
||||||
|
|
||||||
if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) {
|
|
||||||
/**
|
|
||||||
* When manual executions are not being saved, we only soft-delete
|
|
||||||
* the execution so that the user can access its binary data
|
|
||||||
* while building their workflow.
|
|
||||||
*
|
|
||||||
* The manual execution and its binary data will be hard-deleted
|
|
||||||
* on the next pruning cycle after the grace period set by
|
|
||||||
* `EXECUTIONS_DATA_HARD_DELETE_BUFFER`.
|
|
||||||
*/
|
|
||||||
await Container.get(ExecutionRepository).softDelete(this.executionId);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldNotSave =
|
|
||||||
(executionStatus === 'success' && !saveSettings.success) ||
|
|
||||||
(executionStatus !== 'success' && !saveSettings.error);
|
|
||||||
|
|
||||||
if (!isManualMode && shouldNotSave && !fullRunData.waitTill) {
|
|
||||||
await Container.get(ExecutionRepository).hardDelete({
|
|
||||||
workflowId: this.workflowData.id,
|
|
||||||
executionId: this.executionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns WorkflowHooks instance for running the main workflow
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export function getWorkflowHooksMain(
|
|
||||||
data: IWorkflowExecutionDataProcess,
|
|
||||||
executionId: string,
|
|
||||||
): WorkflowHooks {
|
|
||||||
const hookFunctions = hookFunctionsSave();
|
|
||||||
const pushFunctions = hookFunctionsPush();
|
|
||||||
for (const key of Object.keys(pushFunctions)) {
|
|
||||||
const hooks = hookFunctions[key] ?? [];
|
|
||||||
hooks.push.apply(hookFunctions[key], pushFunctions[key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preExecuteFunctions = hookFunctionsPreExecute();
|
|
||||||
for (const key of Object.keys(preExecuteFunctions)) {
|
|
||||||
const hooks = hookFunctions[key] ?? [];
|
|
||||||
hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hookFunctions.nodeExecuteBefore) hookFunctions.nodeExecuteBefore = [];
|
|
||||||
if (!hookFunctions.nodeExecuteAfter) hookFunctions.nodeExecuteAfter = [];
|
|
||||||
|
|
||||||
return new WorkflowHooks(hookFunctions, data.executionMode, executionId, data.workflowData, {
|
|
||||||
pushRef: data.pushRef,
|
|
||||||
retryOf: data.retryOf as string,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
@ -20,7 +20,15 @@ import PCancelable from 'p-cancelable';
|
||||||
import { ActiveExecutions } from '@/active-executions';
|
import { ActiveExecutions } from '@/active-executions';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
|
import { ExecutionNotFoundError } from '@/errors/execution-not-found-error';
|
||||||
|
import { EventService } from '@/events/event.service';
|
||||||
|
import {
|
||||||
|
getWorkflowHooksMain,
|
||||||
|
getWorkflowHooksWorkerExecuter,
|
||||||
|
getWorkflowHooksWorkerMain,
|
||||||
|
} from '@/execution-lifecycle/execution-lifecycle-hooks';
|
||||||
import { ExternalHooks } from '@/external-hooks';
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
|
import { ManualExecutionService } from '@/manual-execution.service';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
import type { ScalingService } from '@/scaling/scaling.service';
|
import type { ScalingService } from '@/scaling/scaling.service';
|
||||||
import type { Job, JobData } from '@/scaling/scaling.types';
|
import type { Job, JobData } from '@/scaling/scaling.types';
|
||||||
|
@ -29,10 +37,6 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da
|
||||||
import { generateFailedExecutionFromError } from '@/workflow-helpers';
|
import { generateFailedExecutionFromError } from '@/workflow-helpers';
|
||||||
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
||||||
|
|
||||||
import { ExecutionNotFoundError } from './errors/execution-not-found-error';
|
|
||||||
import { EventService } from './events/event.service';
|
|
||||||
import { ManualExecutionService } from './manual-execution.service';
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class WorkflowRunner {
|
export class WorkflowRunner {
|
||||||
private scalingService: ScalingService;
|
private scalingService: ScalingService;
|
||||||
|
@ -138,7 +142,7 @@ export class WorkflowRunner {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Create a failed execution with the data for the node, save it and abort execution
|
// Create a failed execution with the data for the node, save it and abort execution
|
||||||
const runData = generateFailedExecutionFromError(data.executionMode, error, error.node);
|
const runData = generateFailedExecutionFromError(data.executionMode, error, error.node);
|
||||||
const workflowHooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId);
|
const workflowHooks = getWorkflowHooksMain(data, executionId);
|
||||||
await workflowHooks.executeHookFunctions('workflowExecuteBefore', [
|
await workflowHooks.executeHookFunctions('workflowExecuteBefore', [
|
||||||
undefined,
|
undefined,
|
||||||
data.executionData,
|
data.executionData,
|
||||||
|
@ -267,7 +271,7 @@ export class WorkflowRunner {
|
||||||
await this.executionRepository.setRunning(executionId); // write
|
await this.executionRepository.setRunning(executionId); // write
|
||||||
|
|
||||||
try {
|
try {
|
||||||
additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId);
|
additionalData.hooks = getWorkflowHooksMain(data, executionId);
|
||||||
|
|
||||||
additionalData.hooks.hookFunctions.sendResponse = [
|
additionalData.hooks.hookFunctions.sendResponse = [
|
||||||
async (response: IExecuteResponsePromiseData): Promise<void> => {
|
async (response: IExecuteResponsePromiseData): Promise<void> => {
|
||||||
|
@ -368,12 +372,9 @@ export class WorkflowRunner {
|
||||||
try {
|
try {
|
||||||
job = await this.scalingService.addJob(jobData, { priority: realtime ? 50 : 100 });
|
job = await this.scalingService.addJob(jobData, { priority: realtime ? 50 : 100 });
|
||||||
|
|
||||||
hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerMain(
|
hooks = getWorkflowHooksWorkerMain(data.executionMode, executionId, data.workflowData, {
|
||||||
data.executionMode,
|
retryOf: data.retryOf ? data.retryOf.toString() : undefined,
|
||||||
executionId,
|
});
|
||||||
data.workflowData,
|
|
||||||
{ retryOf: data.retryOf ? data.retryOf.toString() : undefined },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Normally also workflow should be supplied here but as it only used for sending
|
// Normally also workflow should be supplied here but as it only used for sending
|
||||||
// data to editor-UI is not needed.
|
// data to editor-UI is not needed.
|
||||||
|
@ -381,7 +382,7 @@ export class WorkflowRunner {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
|
// We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
|
||||||
// "workflowExecuteAfter" which we require.
|
// "workflowExecuteAfter" which we require.
|
||||||
const hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter(
|
const hooks = getWorkflowHooksWorkerExecuter(
|
||||||
data.executionMode,
|
data.executionMode,
|
||||||
executionId,
|
executionId,
|
||||||
data.workflowData,
|
data.workflowData,
|
||||||
|
@ -399,7 +400,7 @@ export class WorkflowRunner {
|
||||||
|
|
||||||
// We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
|
// We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
|
||||||
// "workflowExecuteAfter" which we require.
|
// "workflowExecuteAfter" which we require.
|
||||||
const hooksWorker = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter(
|
const hooksWorker = getWorkflowHooksWorkerExecuter(
|
||||||
data.executionMode,
|
data.executionMode,
|
||||||
executionId,
|
executionId,
|
||||||
data.workflowData,
|
data.workflowData,
|
||||||
|
@ -417,7 +418,7 @@ export class WorkflowRunner {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
|
// We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
|
||||||
// "workflowExecuteAfter" which we require.
|
// "workflowExecuteAfter" which we require.
|
||||||
const hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter(
|
const hooks = getWorkflowHooksWorkerExecuter(
|
||||||
data.executionMode,
|
data.executionMode,
|
||||||
executionId,
|
executionId,
|
||||||
data.workflowData,
|
data.workflowData,
|
||||||
|
|
|
@ -371,6 +371,12 @@
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if isHtml}}
|
||||||
|
<div class="form-group">
|
||||||
|
{{{html}}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{#if isTextarea}}
|
{{#if isTextarea}}
|
||||||
<div class='form-group'>
|
<div class='form-group'>
|
||||||
<label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
|
<label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
|
||||||
|
|
|
@ -1,10 +1,85 @@
|
||||||
<html>
|
<html>
|
||||||
<script>
|
<head>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-container {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 2.5rem;
|
||||||
|
fill: #2AA568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #0F1430;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #707183;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="center-container">
|
||||||
|
<div class="left-container">
|
||||||
|
<div class="row">
|
||||||
|
<img src="{{imagePath}}" class="logo" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
class="icon"
|
||||||
|
>
|
||||||
|
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
|
||||||
|
<path
|
||||||
|
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h1>Connection successful</h1>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<p>This window will close automatically in 5 seconds.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
(function messageParent() {
|
(function messageParent() {
|
||||||
const broadcastChannel = new BroadcastChannel('oauth-callback');
|
const broadcastChannel = new BroadcastChannel('oauth-callback');
|
||||||
broadcastChannel.postMessage('success');
|
broadcastChannel.postMessage('success');
|
||||||
})();
|
})();
|
||||||
|
(function autoclose(){
|
||||||
|
setTimeout(function() { window.close(); }, 5000);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
</body>
|
||||||
Got connected. The window can be closed now.
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -116,7 +116,7 @@ describe('OAuth2 API', () => {
|
||||||
.query({ code: 'auth_code', state })
|
.query({ code: 'auth_code', state })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(renderSpy).toHaveBeenCalledWith('oauth-callback');
|
expect(renderSpy).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' });
|
||||||
|
|
||||||
const updatedCredential = await Container.get(CredentialsHelper).getCredentials(
|
const updatedCredential = await Container.get(CredentialsHelper).getCredentials(
|
||||||
credential,
|
credential,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-core",
|
"name": "n8n-core",
|
||||||
"version": "1.75.0",
|
"version": "1.76.0",
|
||||||
"description": "Core functionality of n8n",
|
"description": "Core functionality of n8n",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
// Disable task runners until we have fixed the "run test workflows" test
|
||||||
|
// to mock the Code Node execution
|
||||||
|
process.env.N8N_RUNNERS_ENABLED = 'false';
|
||||||
|
|
||||||
// NOTE: Diagrams in this file have been created with https://asciiflow.com/#/
|
// NOTE: Diagrams in this file have been created with https://asciiflow.com/#/
|
||||||
// If you update the tests, please update the diagrams as well.
|
// If you update the tests, please update the diagrams as well.
|
||||||
// If you add a test, please create a new diagram.
|
// If you add a test, please create a new diagram.
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-design-system",
|
"name": "n8n-design-system",
|
||||||
"version": "1.65.0",
|
"version": "1.66.0",
|
||||||
"main": "src/main.ts",
|
"main": "src/main.ts",
|
||||||
"import": "src/main.ts",
|
"import": "src/main.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-editor-ui",
|
"name": "n8n-editor-ui",
|
||||||
"version": "1.75.0",
|
"version": "1.76.0",
|
||||||
"description": "Workflow Editor UI for n8n",
|
"description": "Workflow Editor UI for n8n",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
|
import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers';
|
||||||
|
import { AI_ASSISTANT_MAX_CONTENT_LENGTH } from '@/constants';
|
||||||
import type { ICredentialsResponse, IRestApiContext } from '@/Interface';
|
import type { ICredentialsResponse, IRestApiContext } from '@/Interface';
|
||||||
import type { AskAiRequest, ChatRequest, ReplaceCodeRequest } from '@/types/assistant.types';
|
import type { AskAiRequest, ChatRequest, ReplaceCodeRequest } from '@/types/assistant.types';
|
||||||
import { makeRestApiRequest, streamRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest, streamRequest } from '@/utils/apiUtils';
|
||||||
|
import { getObjectSizeInKB } from '@/utils/objectUtils';
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
|
||||||
export function chatWithAssistant(
|
export function chatWithAssistant(
|
||||||
|
@ -10,6 +13,15 @@ export function chatWithAssistant(
|
||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
onError: (e: Error) => void,
|
onError: (e: Error) => void,
|
||||||
): void {
|
): void {
|
||||||
|
try {
|
||||||
|
const payloadSize = getObjectSizeInKB(payload.payload);
|
||||||
|
if (payloadSize > AI_ASSISTANT_MAX_CONTENT_LENGTH) {
|
||||||
|
useAIAssistantHelpers().trimPayloadSize(payload);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void streamRequest<ChatRequest.ResponsePayload>(
|
void streamRequest<ChatRequest.ResponsePayload>(
|
||||||
ctx,
|
ctx,
|
||||||
'/ai/chat',
|
'/ai/chat',
|
||||||
|
|
|
@ -230,9 +230,10 @@ describe('CanvasChat', () => {
|
||||||
// Verify workflow execution
|
// Verify workflow execution
|
||||||
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
|
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
runData: {
|
runData: undefined,
|
||||||
'When chat message received': [
|
triggerToStartFrom: {
|
||||||
{
|
name: 'When chat message received',
|
||||||
|
data: {
|
||||||
data: {
|
data: {
|
||||||
main: [
|
main: [
|
||||||
[
|
[
|
||||||
|
@ -251,7 +252,6 @@ describe('CanvasChat', () => {
|
||||||
source: [null],
|
source: [null],
|
||||||
startTime: expect.any(Number),
|
startTime: expect.any(Number),
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import JsonEditor from '@/components/JsonEditor/JsonEditor.vue';
|
import JsonEditor from '@/components/JsonEditor/JsonEditor.vue';
|
||||||
import { renderComponent } from '@/__tests__/render';
|
import { renderComponent } from '@/__tests__/render';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
|
||||||
describe('JsonEditor', () => {
|
describe('JsonEditor', () => {
|
||||||
const renderEditor = (jsonString: string) =>
|
const renderEditor = (jsonString: string) =>
|
||||||
|
@ -13,18 +15,29 @@ describe('JsonEditor', () => {
|
||||||
|
|
||||||
it('renders simple json', async () => {
|
it('renders simple json', async () => {
|
||||||
const modelValue = '{ "testing": [true, 5] }';
|
const modelValue = '{ "testing": [true, 5] }';
|
||||||
const result = renderEditor(modelValue);
|
const { getByRole } = renderEditor(modelValue);
|
||||||
expect(result.container.querySelector('.cm-content')?.textContent).toEqual(modelValue);
|
expect(getByRole('textbox').textContent).toEqual(modelValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders multiline json', async () => {
|
it('renders multiline json', async () => {
|
||||||
const modelValue = '{\n\t"testing": [true, 5]\n}';
|
const modelValue = '{\n\t"testing": [true, 5]\n}';
|
||||||
const result = renderEditor(modelValue);
|
const { getByRole, container } = renderEditor(modelValue);
|
||||||
const gutter = result.container.querySelector('.cm-gutters');
|
const gutter = container.querySelector('.cm-gutters');
|
||||||
expect(gutter?.querySelectorAll('.cm-lineNumbers .cm-gutterElement').length).toEqual(4);
|
expect(gutter?.querySelectorAll('.cm-lineNumbers .cm-gutterElement').length).toEqual(4);
|
||||||
|
|
||||||
const content = result.container.querySelector('.cm-content');
|
const content = getByRole('textbox');
|
||||||
const lines = [...content!.querySelectorAll('.cm-line').values()].map((l) => l.textContent);
|
const lines = [...content.querySelectorAll('.cm-line').values()].map((l) => l.textContent);
|
||||||
expect(lines).toEqual(['{', '\t"testing": [true, 5]', '}']);
|
expect(lines).toEqual(['{', '\t"testing": [true, 5]', '}']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits update:model-value events', async () => {
|
||||||
|
const modelValue = '{ "test": 1 }';
|
||||||
|
|
||||||
|
const { emitted, getByRole } = renderEditor(modelValue);
|
||||||
|
|
||||||
|
const textbox = await waitFor(() => getByRole('textbox'));
|
||||||
|
await userEvent.type(textbox, 'test');
|
||||||
|
|
||||||
|
await waitFor(() => expect(emitted('update:modelValue')).toContainEqual(['test{ "test": 1 }']));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -36,7 +36,6 @@ const emit = defineEmits<{
|
||||||
const jsonEditorRef = ref<HTMLDivElement>();
|
const jsonEditorRef = ref<HTMLDivElement>();
|
||||||
const editor = ref<EditorView | null>(null);
|
const editor = ref<EditorView | null>(null);
|
||||||
const editorState = ref<EditorState | null>(null);
|
const editorState = ref<EditorState | null>(null);
|
||||||
const isDirty = ref(false);
|
|
||||||
|
|
||||||
const extensions = computed(() => {
|
const extensions = computed(() => {
|
||||||
const extensionsToApply: Extension[] = [
|
const extensionsToApply: Extension[] = [
|
||||||
|
@ -66,7 +65,6 @@ const extensions = computed(() => {
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
mappingDropCursor(),
|
mappingDropCursor(),
|
||||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||||
isDirty.value = true;
|
|
||||||
if (!viewUpdate.docChanged || !editor.value) return;
|
if (!viewUpdate.docChanged || !editor.value) return;
|
||||||
emit('update:modelValue', editor.value?.state.doc.toString());
|
emit('update:modelValue', editor.value?.state.doc.toString());
|
||||||
}),
|
}),
|
||||||
|
@ -81,7 +79,6 @@ onMounted(() => {
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (!editor.value) return;
|
if (!editor.value) return;
|
||||||
if (isDirty.value) emit('update:modelValue', editor.value.state.doc.toString());
|
|
||||||
editor.value.destroy();
|
editor.value.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -87,6 +87,41 @@ describe('ProjectMoveResourceModal', () => {
|
||||||
expect(getByText(/Currently there are not any projects or users available/)).toBeVisible();
|
expect(getByText(/Currently there are not any projects or users available/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not hide project select if filter has no result', async () => {
|
||||||
|
const projects = Array.from({ length: 5 }, createProjectListItem);
|
||||||
|
projectsStore.availableProjects = projects;
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
|
data: {
|
||||||
|
resourceType: 'workflow',
|
||||||
|
resourceTypeLabel: 'Workflow',
|
||||||
|
resource: {
|
||||||
|
id: '1',
|
||||||
|
homeProject: {
|
||||||
|
id: projects[0].id,
|
||||||
|
name: projects[0].name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId, getByRole } = renderComponent({ props });
|
||||||
|
|
||||||
|
const projectSelect = getByTestId('project-move-resource-modal-select');
|
||||||
|
const projectSelectInput: HTMLInputElement = getByRole('combobox');
|
||||||
|
expect(projectSelectInput).toBeVisible();
|
||||||
|
expect(projectSelect).toBeVisible();
|
||||||
|
|
||||||
|
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||||
|
expect(projectSelectDropdownItems).toHaveLength(projects.length - 1);
|
||||||
|
|
||||||
|
await userEvent.click(projectSelectInput);
|
||||||
|
await userEvent.type(projectSelectInput, 'non-existing project');
|
||||||
|
|
||||||
|
expect(projectSelect).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
it('should not load workflow if the resource is a credential', async () => {
|
it('should not load workflow if the resource is a credential', async () => {
|
||||||
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
|
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
|
||||||
projectsStore.availableProjects = [createProjectListItem()];
|
projectsStore.availableProjects = [createProjectListItem()];
|
||||||
|
|
|
@ -47,12 +47,14 @@ const availableProjects = computed(() =>
|
||||||
'name',
|
'name',
|
||||||
projectsStore.availableProjects.filter(
|
projectsStore.availableProjects.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.name?.toLowerCase().includes(filter.value.toLowerCase()) &&
|
|
||||||
p.id !== props.data.resource.homeProject?.id &&
|
p.id !== props.data.resource.homeProject?.id &&
|
||||||
(!p.scopes || getResourcePermissions(p.scopes)[props.data.resourceType].create),
|
(!p.scopes || getResourcePermissions(p.scopes)[props.data.resourceType].create),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const filteredProjects = computed(() =>
|
||||||
|
availableProjects.value.filter((p) => p.name?.toLowerCase().includes(filter.value.toLowerCase())),
|
||||||
|
);
|
||||||
const selectedProject = computed(() =>
|
const selectedProject = computed(() =>
|
||||||
availableProjects.value.find((p) => p.id === projectId.value),
|
availableProjects.value.find((p) => p.id === projectId.value),
|
||||||
);
|
);
|
||||||
|
@ -217,7 +219,7 @@ onMounted(async () => {
|
||||||
<N8nIcon icon="search" />
|
<N8nIcon icon="search" />
|
||||||
</template>
|
</template>
|
||||||
<N8nOption
|
<N8nOption
|
||||||
v-for="p in availableProjects"
|
v-for="p in filteredProjects"
|
||||||
:key="p.id"
|
:key="p.id"
|
||||||
:value="p.id"
|
:value="p.id"
|
||||||
:label="p.name"
|
:label="p.name"
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`useBugReporting > should generate the correct reporting URL 1`] = `"https://github.com/n8n-io/n8n/issues/new?labels=bug-report&body=%0A%3C%21--+Please+follow+the+template+below.+Skip+the+questions+that+are+not+relevant+to+you.+--%3E%0A%0A%23%23+Describe+the+problem%2Ferror%2Fquestion%0A%0A%0A%23%23+What+is+the+error+message+%28if+any%29%3F%0A%0A%0A%23%23+Please+share+your+workflow%2Fscreenshots%2Frecording%0A%0A%60%60%60%0A%28Select+the+nodes+on+your+canvas+and+use+the+keyboard+shortcuts+CMD%2BC%2FCTRL%2BC+and+CMD%2BV%2FCTRL%2BV+to+copy+and+paste+the+workflow.%29%0A%60%60%60%0A%0A%0A%23%23+Share+the+output+returned+by+the+last+node%0A%3C%21--+If+you+need+help+with+data+transformations%2C+please+also+share+your+expected+output.+--%3E%0A%0A%0Amocked+debug+info%7D"`;
|
exports[`useBugReporting > should generate the correct reporting URL 1`] = `"https://github.com/n8n-io/n8n/issues/new?labels=bug-report&body=%0A%3C%21--+Please+follow+the+template+below.+Skip+the+questions+that+are+not+relevant+to+you.+--%3E%0A%0A%23%23+Describe+the+problem%2Ferror%2Fquestion%0A%0A%0A%23%23+What+is+the+error+message+%28if+any%29%3F%0A%0A%0A%23%23+Please+share+your+workflow%2Fscreenshots%2Frecording%0A%0A%60%60%60%0A%28Select+the+nodes+on+your+canvas+and+use+the+keyboard+shortcuts+CMD%2BC%2FCTRL%2BC+and+CMD%2BV%2FCTRL%2BV+to+copy+and+paste+the+workflow.%29%0A%E2%9A%A0%EF%B8%8F+WARNING+%E2%9A%A0%EF%B8%8F+If+you+have+sensitive+data+in+your+workflow+%28like+API+keys%29%2C+please+remove+it+before+sharing.%0A%60%60%60%0A%0A%0A%23%23+Share+the+output+returned+by+the+last+node%0A%3C%21--+If+you+need+help+with+data+transformations%2C+please+also+share+your+expected+output.+--%3E%0A%0A%0Amocked+debug+info%7D"`;
|
||||||
|
|
|
@ -0,0 +1,430 @@
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import type { ChatRequest } from '@/types/assistant.types';
|
||||||
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export const PAYLOAD_SIZE_FOR_1_PASS = 4;
|
||||||
|
export const PAYLOAD_SIZE_FOR_2_PASSES = 2;
|
||||||
|
|
||||||
|
export const ERROR_HELPER_TEST_PAYLOAD: ChatRequest.RequestPayload = {
|
||||||
|
payload: {
|
||||||
|
role: 'user',
|
||||||
|
type: 'init-error-helper',
|
||||||
|
user: {
|
||||||
|
firstName: 'Milorad',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
name: 'NodeOperationError',
|
||||||
|
message: "Referenced node doesn't exist",
|
||||||
|
description:
|
||||||
|
"The node <strong>'Hey'</strong> doesn't exist, but it's used in an expression here.",
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: {
|
||||||
|
'0': {
|
||||||
|
id: '0957fbdb-a021-413b-9d42-fc847666f999',
|
||||||
|
name: 'text',
|
||||||
|
value: 'Lorem ipsum dolor sit amet',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
'1': {
|
||||||
|
id: '8efecfa7-8df7-492e-83e7-3d517ad03e60',
|
||||||
|
name: 'foo',
|
||||||
|
value: {
|
||||||
|
value: "={{ $('Hey').json.name }}",
|
||||||
|
resolvedExpressionValue: 'Error in expression: "Referenced node doesn\'t exist"',
|
||||||
|
},
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
id: '6dc70bf3-ba54-4481-b9f5-ce255bdd5fb8',
|
||||||
|
name: 'This is fine',
|
||||||
|
},
|
||||||
|
executionSchema: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SUPPORT_CHAT_TEST_PAYLOAD: ChatRequest.RequestPayload = {
|
||||||
|
payload: {
|
||||||
|
role: 'user',
|
||||||
|
type: 'init-support-chat',
|
||||||
|
user: {
|
||||||
|
firstName: 'Milorad',
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
currentView: {
|
||||||
|
name: VIEWS.WORKFLOW,
|
||||||
|
description:
|
||||||
|
'The user is currently looking at the current workflow in n8n editor, without any specific node selected.',
|
||||||
|
},
|
||||||
|
activeNodeInfo: {
|
||||||
|
node: {
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: {
|
||||||
|
'0': {
|
||||||
|
id: '969e86d0-76de-44f6-b07d-44a8a953f564',
|
||||||
|
name: 'name',
|
||||||
|
value: {
|
||||||
|
value: "={{ $('Edit Fields 2').name }}",
|
||||||
|
resolvedExpressionValue:
|
||||||
|
'Error in expression: "Referenced node doesn\'t exist"',
|
||||||
|
},
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7',
|
||||||
|
name: 'Edit Fields1',
|
||||||
|
},
|
||||||
|
executionStatus: {
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
name: 'NodeOperationError',
|
||||||
|
message: "Referenced node doesn't exist",
|
||||||
|
stack:
|
||||||
|
"NodeOperationError: Referenced node doesn't exist\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/manual.mode.ts:256:9)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/SetV2.node.ts:351:48)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:31)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1505:38\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2066:11",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
referencedNodes: [],
|
||||||
|
},
|
||||||
|
currentWorkflow: {
|
||||||
|
name: '🧪 Assistant context test',
|
||||||
|
active: false,
|
||||||
|
connections: {
|
||||||
|
'When clicking ‘Test workflow’': {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node: 'Edit Fields',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Edit Fields': {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node: 'Bad request no chat found',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'Slack',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'Edit Fields1',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'Edit Fields2',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
notice: '',
|
||||||
|
},
|
||||||
|
id: 'c457ff96-3b0c-4dbc-b47f-dc88396a46ae',
|
||||||
|
name: 'When clicking ‘Test workflow’',
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
position: [-60, 200],
|
||||||
|
typeVersion: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
resource: 'chat',
|
||||||
|
operation: 'get',
|
||||||
|
chatId: '13',
|
||||||
|
},
|
||||||
|
id: '60ddc045-d4e3-4b62-9832-12ecf78937a6',
|
||||||
|
name: 'Bad request no chat found',
|
||||||
|
type: 'n8n-nodes-base.telegram',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [540, 0],
|
||||||
|
issues: {},
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: '70448b12-9b2b-4bfb-abee-6432c4c58de1',
|
||||||
|
name: 'name',
|
||||||
|
value: 'Joe',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [200, 200],
|
||||||
|
id: '0a831739-13cd-4541-b20b-7db73abbcaf0',
|
||||||
|
name: 'Edit Fields',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
authentication: 'oAuth2',
|
||||||
|
resource: 'channel',
|
||||||
|
operation: 'archive',
|
||||||
|
channelId: {
|
||||||
|
__rl: true,
|
||||||
|
mode: 'list',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
typeVersion: 2.2,
|
||||||
|
position: [540, 200],
|
||||||
|
id: 'aff7471e-b2bc-4274-abe1-97897a17eaa6',
|
||||||
|
name: 'Slack',
|
||||||
|
webhookId: '7f8b574c-7729-4220-bbe9-bf5aa382406a',
|
||||||
|
credentials: {
|
||||||
|
slackOAuth2Api: {
|
||||||
|
id: 'mZRj4wi3gavIzu9b',
|
||||||
|
name: 'Slack account',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: '969e86d0-76de-44f6-b07d-44a8a953f564',
|
||||||
|
name: 'name',
|
||||||
|
value: "={{ $('Edit Fields 2').name }}",
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [540, 400],
|
||||||
|
id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7',
|
||||||
|
name: 'Edit Fields1',
|
||||||
|
issues: {
|
||||||
|
execution: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: '9bdfc283-64f7-41c5-9a55-b8d8ccbe3e9d',
|
||||||
|
name: 'age',
|
||||||
|
value: '={{ $json.name }}',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [440, 560],
|
||||||
|
id: '34e56e14-d1a9-4a73-9208-15d39771a9ba',
|
||||||
|
name: 'Edit Fields2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
executionData: {
|
||||||
|
runData: {
|
||||||
|
'When clicking ‘Test workflow’': [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737540693122,
|
||||||
|
executionTime: 1,
|
||||||
|
source: [],
|
||||||
|
executionStatus: 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Edit Fields': [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737540693124,
|
||||||
|
executionTime: 2,
|
||||||
|
source: [
|
||||||
|
{
|
||||||
|
previousNode: 'When clicking ‘Test workflow’',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executionStatus: 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Bad request no chat found': [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737540693126,
|
||||||
|
executionTime: 0,
|
||||||
|
source: [
|
||||||
|
{
|
||||||
|
previousNode: 'Edit Fields',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executionStatus: 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Slack: [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737540693127,
|
||||||
|
executionTime: 0,
|
||||||
|
source: [
|
||||||
|
{
|
||||||
|
previousNode: 'Edit Fields',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executionStatus: 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Edit Fields1': [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737540693127,
|
||||||
|
executionTime: 28,
|
||||||
|
source: [
|
||||||
|
{
|
||||||
|
previousNode: 'Edit Fields',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executionStatus: 'error',
|
||||||
|
// @ts-expect-error Incomplete mock objects are expected
|
||||||
|
error: {
|
||||||
|
level: 'warning',
|
||||||
|
tags: {
|
||||||
|
packageName: 'workflow',
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
itemIndex: 0,
|
||||||
|
nodeCause: 'Edit Fields 2',
|
||||||
|
descriptionKey: 'nodeNotFound',
|
||||||
|
parameter: 'assignments',
|
||||||
|
},
|
||||||
|
functionality: 'regular',
|
||||||
|
name: 'NodeOperationError',
|
||||||
|
timestamp: 1737540693141,
|
||||||
|
node: {
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: '969e86d0-76de-44f6-b07d-44a8a953f564',
|
||||||
|
name: 'name',
|
||||||
|
value: "={{ $('Edit Fields 2').name }}",
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [540, 400],
|
||||||
|
id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7',
|
||||||
|
name: 'Edit Fields1',
|
||||||
|
},
|
||||||
|
messages: [],
|
||||||
|
message: "Referenced node doesn't exist",
|
||||||
|
stack:
|
||||||
|
"NodeOperationError: Referenced node doesn't exist\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/manual.mode.ts:256:9)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/SetV2.node.ts:351:48)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:31)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1505:38\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2066:11",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// @ts-expect-error Incomplete mock objects are expected
|
||||||
|
error: {
|
||||||
|
level: 'warning',
|
||||||
|
tags: {
|
||||||
|
packageName: 'workflow',
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
itemIndex: 0,
|
||||||
|
nodeCause: 'Edit Fields 2',
|
||||||
|
descriptionKey: 'nodeNotFound',
|
||||||
|
parameter: 'assignments',
|
||||||
|
},
|
||||||
|
functionality: 'regular',
|
||||||
|
name: 'NodeOperationError',
|
||||||
|
timestamp: 1737540693141,
|
||||||
|
node: {
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: '969e86d0-76de-44f6-b07d-44a8a953f564',
|
||||||
|
name: 'name',
|
||||||
|
value: "={{ $('Edit Fields 2').name }}",
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [540, 400],
|
||||||
|
id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7',
|
||||||
|
name: 'Edit Fields1',
|
||||||
|
},
|
||||||
|
messages: [],
|
||||||
|
message: "Referenced node doesn't exist",
|
||||||
|
stack:
|
||||||
|
"NodeOperationError: Referenced node doesn't exist\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/manual.mode.ts:256:9)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/SetV2.node.ts:351:48)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:31)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1505:38\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2066:11",
|
||||||
|
},
|
||||||
|
lastNodeExecuted: 'Edit Fields1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
question: 'Hey',
|
||||||
|
},
|
||||||
|
};
|
|
@ -4,6 +4,13 @@ import { useAIAssistantHelpers } from './useAIAssistantHelpers';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import type { IWorkflowDb } from '@/Interface';
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
|
import type { ChatRequest } from '@/types/assistant.types';
|
||||||
|
import {
|
||||||
|
ERROR_HELPER_TEST_PAYLOAD,
|
||||||
|
PAYLOAD_SIZE_FOR_1_PASS,
|
||||||
|
PAYLOAD_SIZE_FOR_2_PASSES,
|
||||||
|
SUPPORT_CHAT_TEST_PAYLOAD,
|
||||||
|
} from './useAIAssistantHelpers.test.constants';
|
||||||
|
|
||||||
const referencedNodesTestCases: Array<{ caseName: string; node: INode; expected: string[] }> = [
|
const referencedNodesTestCases: Array<{ caseName: string; node: INode; expected: string[] }> = [
|
||||||
{
|
{
|
||||||
|
@ -549,3 +556,67 @@ describe('Simplify assistant payloads', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Trim Payload Size', () => {
|
||||||
|
let aiAssistantHelpers: ReturnType<typeof useAIAssistantHelpers>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia());
|
||||||
|
aiAssistantHelpers = useAIAssistantHelpers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should trim active node parameters in error helper payload', () => {
|
||||||
|
const payload = ERROR_HELPER_TEST_PAYLOAD;
|
||||||
|
aiAssistantHelpers.trimPayloadSize(payload);
|
||||||
|
expect((payload.payload as ChatRequest.InitErrorHelper).node.parameters).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should trim all node parameters in support chat', () => {
|
||||||
|
// Testing the scenario where only one trimming pass is needed
|
||||||
|
// (payload is under the limit after removing all node parameters and execution data)
|
||||||
|
const payload: ChatRequest.RequestPayload = SUPPORT_CHAT_TEST_PAYLOAD;
|
||||||
|
const supportPayload: ChatRequest.InitSupportChat =
|
||||||
|
payload.payload as ChatRequest.InitSupportChat;
|
||||||
|
|
||||||
|
// Trimming to 4kb should be successful
|
||||||
|
expect(() =>
|
||||||
|
aiAssistantHelpers.trimPayloadSize(payload, PAYLOAD_SIZE_FOR_1_PASS),
|
||||||
|
).not.toThrow();
|
||||||
|
// All active node parameters should be removed
|
||||||
|
expect(supportPayload?.context?.activeNodeInfo?.node?.parameters).toEqual({});
|
||||||
|
// Also, all node parameters in the workflow should be removed
|
||||||
|
supportPayload.context?.currentWorkflow?.nodes?.forEach((node) => {
|
||||||
|
expect(node.parameters).toEqual({});
|
||||||
|
});
|
||||||
|
// Node parameters in the execution data should be removed
|
||||||
|
expect(supportPayload.context?.executionData?.runData).toEqual({});
|
||||||
|
if (
|
||||||
|
supportPayload.context?.executionData?.error &&
|
||||||
|
'node' in supportPayload.context.executionData.error
|
||||||
|
) {
|
||||||
|
expect(supportPayload.context?.executionData?.error?.node?.parameters).toEqual({});
|
||||||
|
}
|
||||||
|
// Context object should still be there
|
||||||
|
expect(supportPayload.context).to.be.an('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should trim the whole context in support chat', () => {
|
||||||
|
// Testing the scenario where both trimming passes are needed
|
||||||
|
// (payload is over the limit after removing all node parameters and execution data)
|
||||||
|
const payload: ChatRequest.RequestPayload = SUPPORT_CHAT_TEST_PAYLOAD;
|
||||||
|
const supportPayload: ChatRequest.InitSupportChat =
|
||||||
|
payload.payload as ChatRequest.InitSupportChat;
|
||||||
|
|
||||||
|
// Trimming should be successful
|
||||||
|
expect(() =>
|
||||||
|
aiAssistantHelpers.trimPayloadSize(payload, PAYLOAD_SIZE_FOR_2_PASSES),
|
||||||
|
).not.toThrow();
|
||||||
|
// The whole context object should be removed
|
||||||
|
expect(supportPayload.context).not.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should throw an error if payload is too big after trimming', () => {
|
||||||
|
const payload = ERROR_HELPER_TEST_PAYLOAD;
|
||||||
|
expect(() => aiAssistantHelpers.trimPayloadSize(payload, 0.2)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -14,9 +14,10 @@ import { executionDataToJson, getMainAuthField, getNodeAuthOptions } from '@/uti
|
||||||
import type { ChatRequest } from '@/types/assistant.types';
|
import type { ChatRequest } from '@/types/assistant.types';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useDataSchema } from './useDataSchema';
|
import { useDataSchema } from './useDataSchema';
|
||||||
import { VIEWS } from '@/constants';
|
import { AI_ASSISTANT_MAX_CONTENT_LENGTH, VIEWS } from '@/constants';
|
||||||
import { useI18n } from './useI18n';
|
import { useI18n } from './useI18n';
|
||||||
import type { IWorkflowDb } from '@/Interface';
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
|
import { getObjectSizeInKB } from '@/utils/objectUtils';
|
||||||
|
|
||||||
const CANVAS_VIEWS = [VIEWS.NEW_WORKFLOW, VIEWS.WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
const CANVAS_VIEWS = [VIEWS.NEW_WORKFLOW, VIEWS.WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||||
const EXECUTION_VIEWS = [VIEWS.EXECUTION_PREVIEW];
|
const EXECUTION_VIEWS = [VIEWS.EXECUTION_PREVIEW];
|
||||||
|
@ -251,6 +252,64 @@ export const useAIAssistantHelpers = () => {
|
||||||
nodes: workflow.nodes,
|
nodes: workflow.nodes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces AI Assistant request payload size to make it fit the specified content length.
|
||||||
|
* If, after two passes, the payload is still too big, throws an error'
|
||||||
|
* @param payload The request payload to trim
|
||||||
|
* @param size The maximum size of the payload in KB
|
||||||
|
*/
|
||||||
|
const trimPayloadToSize = (
|
||||||
|
payload: ChatRequest.RequestPayload,
|
||||||
|
size = AI_ASSISTANT_MAX_CONTENT_LENGTH,
|
||||||
|
): void => {
|
||||||
|
const requestPayload = payload.payload;
|
||||||
|
// For support chat, remove parameters from the active node object and all nodes in the workflow
|
||||||
|
if (requestPayload.type === 'init-support-chat') {
|
||||||
|
if (requestPayload.context?.activeNodeInfo?.node) {
|
||||||
|
requestPayload.context.activeNodeInfo.node.parameters = {};
|
||||||
|
}
|
||||||
|
if (requestPayload.context?.currentWorkflow) {
|
||||||
|
requestPayload.context.currentWorkflow?.nodes?.forEach((node) => {
|
||||||
|
node.parameters = {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (requestPayload.context?.executionData?.runData) {
|
||||||
|
requestPayload.context.executionData.runData = {};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
requestPayload.context?.executionData?.error &&
|
||||||
|
'node' in requestPayload.context?.executionData?.error
|
||||||
|
) {
|
||||||
|
if (requestPayload.context?.executionData?.error?.node) {
|
||||||
|
requestPayload.context.executionData.error.node.parameters = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the payload is still too big, remove the whole context object
|
||||||
|
if (getRequestPayloadSize(payload) > size) {
|
||||||
|
requestPayload.context = undefined;
|
||||||
|
}
|
||||||
|
// For error helper, remove parameters from the active node object
|
||||||
|
// This will leave just the error, user info and basic node structure in the payload
|
||||||
|
} else if (requestPayload.type === 'init-error-helper') {
|
||||||
|
requestPayload.node.parameters = {};
|
||||||
|
}
|
||||||
|
// If the payload is still too big, throw an error that will be shown to the user
|
||||||
|
if (getRequestPayloadSize(payload) > size) {
|
||||||
|
throw new Error(locale.baseText('aiAssistant.payloadTooBig.message'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the size of the request payload in KB, returns 0 if the payload is not a valid object
|
||||||
|
*/
|
||||||
|
const getRequestPayloadSize = (payload: ChatRequest.RequestPayload): number => {
|
||||||
|
try {
|
||||||
|
return getObjectSizeInKB(payload.payload);
|
||||||
|
} catch (error) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
processNodeForAssistant,
|
processNodeForAssistant,
|
||||||
getNodeInfoForAssistant,
|
getNodeInfoForAssistant,
|
||||||
|
@ -261,5 +320,6 @@ export const useAIAssistantHelpers = () => {
|
||||||
getReferencedNodes,
|
getReferencedNodes,
|
||||||
simplifyResultData,
|
simplifyResultData,
|
||||||
simplifyWorkflowForAssistant,
|
simplifyWorkflowForAssistant,
|
||||||
|
trimPayloadSize: trimPayloadToSize,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,6 +15,7 @@ const REPORT_TEMPLATE = `
|
||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
(Select the nodes on your canvas and use the keyboard shortcuts CMD+C/CTRL+C and CMD+V/CTRL+V to copy and paste the workflow.)
|
(Select the nodes on your canvas and use the keyboard shortcuts CMD+C/CTRL+C and CMD+V/CTRL+V to copy and paste the workflow.)
|
||||||
|
⚠️ WARNING ⚠️ If you have sensitive data in your workflow (like API keys), please remove it before sharing.
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ import { useToast } from './useToast';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { captor, mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
vi.mock('@/stores/workflows.store', () => ({
|
vi.mock('@/stores/workflows.store', () => ({
|
||||||
useWorkflowsStore: vi.fn().mockReturnValue({
|
useWorkflowsStore: vi.fn().mockReturnValue({
|
||||||
|
@ -409,27 +409,28 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
const mockExecutionResponse = { executionId: '123' };
|
const mockExecutionResponse = { executionId: '123' };
|
||||||
const mockRunData = { nodeName: [] };
|
const mockRunData = { nodeName: [] };
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
const dataCaptor = captor();
|
||||||
|
const workflow = mock<Workflow>({ name: 'Test Workflow' });
|
||||||
|
workflow.getParentNodes.mockReturnValue([]);
|
||||||
|
|
||||||
vi.mocked(useLocalStorage).mockReturnValueOnce(ref(0));
|
vi.mocked(useLocalStorage).mockReturnValueOnce(ref(0));
|
||||||
vi.mocked(rootStore).pushConnectionActive = true;
|
vi.mocked(rootStore).pushConnectionActive = true;
|
||||||
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
||||||
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
||||||
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
||||||
name: 'Test Workflow',
|
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(
|
||||||
} as Workflow);
|
mock<IWorkflowData>({ id: 'workflowId', nodes: [] }),
|
||||||
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
|
);
|
||||||
id: 'workflowId',
|
|
||||||
nodes: [],
|
|
||||||
} as unknown as IWorkflowData);
|
|
||||||
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
|
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
|
||||||
|
|
||||||
// ACT
|
// ACT
|
||||||
const result = await runWorkflow({});
|
const result = await runWorkflow({ destinationNode: 'some node name' });
|
||||||
|
|
||||||
// ASSERT
|
// ASSERT
|
||||||
expect(result).toEqual(mockExecutionResponse);
|
expect(result).toEqual(mockExecutionResponse);
|
||||||
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1);
|
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1);
|
||||||
expect(vi.mocked(workflowsStore.setWorkflowExecutionData).mock.calls[0][0]).toMatchObject({
|
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledWith(dataCaptor);
|
||||||
|
expect(dataCaptor.value).toMatchObject({
|
||||||
data: { resultData: { runData: {} } },
|
data: { resultData: { runData: {} } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -439,18 +440,47 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
const mockExecutionResponse = { executionId: '123' };
|
const mockExecutionResponse = { executionId: '123' };
|
||||||
const mockRunData = { nodeName: [] };
|
const mockRunData = { nodeName: [] };
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
const dataCaptor = captor();
|
||||||
|
const workflow = mock<Workflow>({ name: 'Test Workflow' });
|
||||||
|
workflow.getParentNodes.mockReturnValue([]);
|
||||||
|
|
||||||
vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1));
|
vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1));
|
||||||
vi.mocked(rootStore).pushConnectionActive = true;
|
vi.mocked(rootStore).pushConnectionActive = true;
|
||||||
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
||||||
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
||||||
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
||||||
name: 'Test Workflow',
|
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(
|
||||||
} as Workflow);
|
mock<IWorkflowData>({ id: 'workflowId', nodes: [] }),
|
||||||
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
|
);
|
||||||
id: 'workflowId',
|
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
|
||||||
nodes: [],
|
|
||||||
} as unknown as IWorkflowData);
|
// ACT
|
||||||
|
const result = await runWorkflow({ destinationNode: 'some node name' });
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(result).toEqual(mockExecutionResponse);
|
||||||
|
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledWith(dataCaptor);
|
||||||
|
expect(dataCaptor.value).toMatchObject({ data: { resultData: { runData: mockRunData } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send run data if it's not a partial execution even if `PartialExecution.version` is set to 1", async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const mockExecutionResponse = { executionId: '123' };
|
||||||
|
const mockRunData = { nodeName: [] };
|
||||||
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
const dataCaptor = captor();
|
||||||
|
const workflow = mock<Workflow>({ name: 'Test Workflow' });
|
||||||
|
workflow.getParentNodes.mockReturnValue([]);
|
||||||
|
|
||||||
|
vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1));
|
||||||
|
vi.mocked(rootStore).pushConnectionActive = true;
|
||||||
|
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
|
||||||
|
vi.mocked(workflowsStore).nodesIssuesExist = false;
|
||||||
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
||||||
|
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(
|
||||||
|
mock<IWorkflowData>({ id: 'workflowId', nodes: [] }),
|
||||||
|
);
|
||||||
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
|
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
|
||||||
|
|
||||||
// ACT
|
// ACT
|
||||||
|
@ -458,10 +488,9 @@ describe('useRunWorkflow({ router })', () => {
|
||||||
|
|
||||||
// ASSERT
|
// ASSERT
|
||||||
expect(result).toEqual(mockExecutionResponse);
|
expect(result).toEqual(mockExecutionResponse);
|
||||||
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1);
|
expect(workflowsStore.runWorkflow).toHaveBeenCalledTimes(1);
|
||||||
expect(vi.mocked(workflowsStore.setWorkflowExecutionData).mock.calls[0][0]).toMatchObject({
|
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(dataCaptor);
|
||||||
data: { resultData: { runData: mockRunData } },
|
expect(dataCaptor.value).toHaveProperty('runData', undefined);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -264,14 +264,23 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
// 0 is the old flow
|
// 0 is the old flow
|
||||||
// 1 is the new flow
|
// 1 is the new flow
|
||||||
const partialExecutionVersion = useLocalStorage('PartialExecution.version', -1);
|
const partialExecutionVersion = useLocalStorage('PartialExecution.version', -1);
|
||||||
|
// partial executions must have a destination node
|
||||||
|
const isPartialExecution = options.destinationNode !== undefined;
|
||||||
const startRunData: IStartRunData = {
|
const startRunData: IStartRunData = {
|
||||||
workflowData,
|
workflowData,
|
||||||
// With the new partial execution version the backend decides what run
|
runData: !isPartialExecution
|
||||||
// data to use and what to ignore.
|
? // if it's a full execution we don't want to send any run data
|
||||||
runData: partialExecutionVersion.value === 1 ? (runData ?? undefined) : newRunData,
|
undefined
|
||||||
|
: partialExecutionVersion.value === 1
|
||||||
|
? // With the new partial execution version the backend decides
|
||||||
|
//what run data to use and what to ignore.
|
||||||
|
(runData ?? undefined)
|
||||||
|
: // for v0 we send the run data the FE constructed
|
||||||
|
newRunData,
|
||||||
startNodes,
|
startNodes,
|
||||||
triggerToStartFrom,
|
triggerToStartFrom,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ('destinationNode' in options) {
|
if ('destinationNode' in options) {
|
||||||
startRunData.destinationNode = options.destinationNode;
|
startRunData.destinationNode = options.destinationNode;
|
||||||
}
|
}
|
||||||
|
|
|
@ -907,3 +907,5 @@ export const APP_MODALS_ELEMENT_ID = 'app-modals';
|
||||||
export const NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL = 'new-sample-sub-workflow-created';
|
export const NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL = 'new-sample-sub-workflow-created';
|
||||||
|
|
||||||
export const AI_NODES_PACKAGE_NAME = '@n8n/n8n-nodes-langchain';
|
export const AI_NODES_PACKAGE_NAME = '@n8n/n8n-nodes-langchain';
|
||||||
|
|
||||||
|
export const AI_ASSISTANT_MAX_CONTENT_LENGTH = 100; // in kilobytes
|
||||||
|
|
|
@ -155,7 +155,8 @@
|
||||||
"aiAssistant.newSessionModal.message": "You already have an active AI Assistant session. Starting a new session will clear your current conversation history.",
|
"aiAssistant.newSessionModal.message": "You already have an active AI Assistant session. Starting a new session will clear your current conversation history.",
|
||||||
"aiAssistant.newSessionModal.question": "Are you sure you want to start a new session?",
|
"aiAssistant.newSessionModal.question": "Are you sure you want to start a new session?",
|
||||||
"aiAssistant.newSessionModal.confirm": "Start new session",
|
"aiAssistant.newSessionModal.confirm": "Start new session",
|
||||||
"aiAssistant.serviceError.message": "Unable to connect to n8n's AI service",
|
"aiAssistant.serviceError.message": "Unable to connect to n8n's AI service ({message})",
|
||||||
|
"aiAssistant.payloadTooBig.message": "Payload size is too large",
|
||||||
"aiAssistant.codeUpdated.message.title": "Assistant modified workflow",
|
"aiAssistant.codeUpdated.message.title": "Assistant modified workflow",
|
||||||
"aiAssistant.codeUpdated.message.body1": "Open the",
|
"aiAssistant.codeUpdated.message.body1": "Open the",
|
||||||
"aiAssistant.codeUpdated.message.body2": "node to see the changes",
|
"aiAssistant.codeUpdated.message.body2": "node to see the changes",
|
||||||
|
|
|
@ -283,7 +283,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
assistantThinkingMessage.value = undefined;
|
assistantThinkingMessage.value = undefined;
|
||||||
addAssistantError(
|
addAssistantError(
|
||||||
`${locale.baseText('aiAssistant.serviceError.message')}: (${e.message})`,
|
locale.baseText('aiAssistant.serviceError.message', { interpolate: { message: e.message } }),
|
||||||
id,
|
id,
|
||||||
retry,
|
retry,
|
||||||
);
|
);
|
||||||
|
@ -487,10 +487,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
openChat();
|
openChat();
|
||||||
|
|
||||||
streaming.value = true;
|
streaming.value = true;
|
||||||
chatWithAssistant(
|
const payload: ChatRequest.RequestPayload['payload'] = {
|
||||||
rootStore.restApiContext,
|
|
||||||
{
|
|
||||||
payload: {
|
|
||||||
role: 'user',
|
role: 'user',
|
||||||
type: 'init-error-helper',
|
type: 'init-error-helper',
|
||||||
user: {
|
user: {
|
||||||
|
@ -504,7 +501,11 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
nodeInputData,
|
nodeInputData,
|
||||||
executionSchema: schemas,
|
executionSchema: schemas,
|
||||||
authType,
|
authType,
|
||||||
},
|
};
|
||||||
|
chatWithAssistant(
|
||||||
|
rootStore.restApiContext,
|
||||||
|
{
|
||||||
|
payload,
|
||||||
},
|
},
|
||||||
(msg) => onEachStreamingMessage(msg, id),
|
(msg) => onEachStreamingMessage(msg, id),
|
||||||
() => onDoneStreaming(id),
|
() => onDoneStreaming(id),
|
||||||
|
|
|
@ -58,7 +58,7 @@ export namespace ChatRequest {
|
||||||
user: {
|
user: {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
};
|
};
|
||||||
context?: UserContext;
|
context?: UserContext & WorkflowContext;
|
||||||
workflowContext?: WorkflowContext;
|
workflowContext?: WorkflowContext;
|
||||||
question: string;
|
question: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { STREAM_SEPERATOR, streamRequest } from './apiUtils';
|
import { ResponseError, STREAM_SEPERATOR, streamRequest } from './apiUtils';
|
||||||
|
|
||||||
describe('streamRequest', () => {
|
describe('streamRequest', () => {
|
||||||
it('should stream data from the API endpoint', async () => {
|
it('should stream data from the API endpoint', async () => {
|
||||||
|
@ -54,6 +54,54 @@ describe('streamRequest', () => {
|
||||||
expect(onErrorMock).not.toHaveBeenCalled();
|
expect(onErrorMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should stream error response from the API endpoint', async () => {
|
||||||
|
const testError = { code: 500, message: 'Error happened' };
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const mockResponse = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(JSON.stringify(testError)));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
body: mockResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
const onChunkMock = vi.fn();
|
||||||
|
const onDoneMock = vi.fn();
|
||||||
|
const onErrorMock = vi.fn();
|
||||||
|
|
||||||
|
await streamRequest(
|
||||||
|
{
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
pushRef: '',
|
||||||
|
},
|
||||||
|
'/data',
|
||||||
|
{ key: 'value' },
|
||||||
|
onChunkMock,
|
||||||
|
onDoneMock,
|
||||||
|
onErrorMock,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ key: 'value' }),
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'browser-id': expect.stringContaining('-'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onChunkMock).not.toHaveBeenCalled();
|
||||||
|
expect(onErrorMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onErrorMock).toHaveBeenCalledWith(new ResponseError(testError.message));
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle broken stream data', async () => {
|
it('should handle broken stream data', async () => {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const mockResponse = new ReadableStream({
|
const mockResponse = new ReadableStream({
|
||||||
|
|
|
@ -198,7 +198,7 @@ export function unflattenExecutionData(fullExecutionData: IExecutionFlattedRespo
|
||||||
return returnData;
|
return returnData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function streamRequest<T>(
|
export async function streamRequest<T extends object>(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
apiEndpoint: string,
|
apiEndpoint: string,
|
||||||
payload: object,
|
payload: object,
|
||||||
|
@ -220,7 +220,7 @@ export async function streamRequest<T>(
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${context.baseUrl}${apiEndpoint}`, assistantRequest);
|
const response = await fetch(`${context.baseUrl}${apiEndpoint}`, assistantRequest);
|
||||||
|
|
||||||
if (response.ok && response.body) {
|
if (response.body) {
|
||||||
// Handle the streaming response
|
// Handle the streaming response
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder('utf-8');
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
@ -252,7 +252,18 @@ export async function streamRequest<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (response.ok) {
|
||||||
|
// Call chunk callback if request was successful
|
||||||
onChunk?.(data);
|
onChunk?.(data);
|
||||||
|
} else {
|
||||||
|
// Otherwise, call error callback
|
||||||
|
const message = 'message' in data ? data.message : response.statusText;
|
||||||
|
onError?.(
|
||||||
|
new ResponseError(String(message), {
|
||||||
|
httpStatusCode: response.status,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
onError?.(e);
|
onError?.(e);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { isObjectOrArray, isObject, searchInObject } from '@/utils/objectUtils';
|
import { isObjectOrArray, isObject, searchInObject, getObjectSizeInKB } from '@/utils/objectUtils';
|
||||||
|
|
||||||
const testData = [1, '', true, null, undefined, new Date(), () => {}].map((value) => [
|
const testData = [1, '', true, null, undefined, new Date(), () => {}].map((value) => [
|
||||||
value,
|
value,
|
||||||
|
@ -95,4 +95,63 @@ describe('objectUtils', () => {
|
||||||
assert(searchInObject({ a: ['b', { c: 'd' }] }, 'd'));
|
assert(searchInObject({ a: ['b', { c: 'd' }] }, 'd'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getObjectSizeInKB', () => {
|
||||||
|
// Test null/undefined cases
|
||||||
|
it('returns 0 for null', () => {
|
||||||
|
expect(getObjectSizeInKB(null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for undefined', () => {
|
||||||
|
expect(getObjectSizeInKB(undefined)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test empty objects/arrays
|
||||||
|
it('returns correct size for empty object', () => {
|
||||||
|
expect(getObjectSizeInKB({})).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct size for empty array', () => {
|
||||||
|
expect(getObjectSizeInKB([])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test regular cases
|
||||||
|
it('calculates size for simple object correctly', () => {
|
||||||
|
const obj = { name: 'test' };
|
||||||
|
expect(getObjectSizeInKB(obj)).toBe(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates size for array correctly', () => {
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
expect(getObjectSizeInKB(arr)).toBe(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates size for nested object correctly', () => {
|
||||||
|
const obj = {
|
||||||
|
name: 'test',
|
||||||
|
nested: {
|
||||||
|
value: 123,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(getObjectSizeInKB(obj)).toBe(0.04);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test error cases
|
||||||
|
it('throws error for circular reference', () => {
|
||||||
|
type CircularObj = {
|
||||||
|
name: string;
|
||||||
|
self?: CircularObj;
|
||||||
|
};
|
||||||
|
|
||||||
|
const obj: CircularObj = { name: 'test' };
|
||||||
|
obj.self = obj;
|
||||||
|
|
||||||
|
expect(() => getObjectSizeInKB(obj)).toThrow('Failed to calculate object size');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles special characters correctly', () => {
|
||||||
|
const obj = { name: '测试' };
|
||||||
|
expect(getObjectSizeInKB(obj)).toBe(0.02);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -18,3 +18,35 @@ export const searchInObject = (obj: ObjectOrArray, searchString: string): boolea
|
||||||
? searchInObject(entry, searchString)
|
? searchInObject(entry, searchString)
|
||||||
: entry?.toString().toLowerCase().includes(searchString.toLowerCase()),
|
: entry?.toString().toLowerCase().includes(searchString.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the size of a stringified object in KB.
|
||||||
|
* @param {unknown} obj - The object to calculate the size of
|
||||||
|
* @returns {number} The size of the object in KB
|
||||||
|
* @throws {Error} If the object is not serializable
|
||||||
|
*/
|
||||||
|
export const getObjectSizeInKB = (obj: unknown): number => {
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(typeof obj === 'object' && Object.keys(obj).length === 0) ||
|
||||||
|
(Array.isArray(obj) && obj.length === 0)
|
||||||
|
) {
|
||||||
|
// "{}" and "[]" both take 2 bytes in UTF-8
|
||||||
|
return Number((2 / 1024).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const str = JSON.stringify(obj);
|
||||||
|
// Using TextEncoder to get actual UTF-8 byte length (what we see in chrome dev tools)
|
||||||
|
const bytes = new TextEncoder().encode(str).length;
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
return Number(kb.toFixed(2));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to calculate object size: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-node-dev",
|
"name": "n8n-node-dev",
|
||||||
"version": "1.75.0",
|
"version": "1.76.0",
|
||||||
"description": "CLI to simplify n8n credentials/node development",
|
"description": "CLI to simplify n8n credentials/node development",
|
||||||
"main": "dist/src/index",
|
"main": "dist/src/index",
|
||||||
"types": "dist/src/index.d.ts",
|
"types": "dist/src/index.d.ts",
|
||||||
|
|
|
@ -138,6 +138,7 @@ export type AwsCredentialsType = {
|
||||||
sesEndpoint?: string;
|
sesEndpoint?: string;
|
||||||
sqsEndpoint?: string;
|
sqsEndpoint?: string;
|
||||||
s3Endpoint?: string;
|
s3Endpoint?: string;
|
||||||
|
ssmEndpoint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Some AWS services are global and don't have a region
|
// Some AWS services are global and don't have a region
|
||||||
|
@ -294,6 +295,19 @@ export class Aws implements ICredentialType {
|
||||||
default: '',
|
default: '',
|
||||||
placeholder: 'https://s3.{region}.amazonaws.com',
|
placeholder: 'https://s3.{region}.amazonaws.com',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'SSM Endpoint',
|
||||||
|
name: 'ssmEndpoint',
|
||||||
|
description: 'Endpoint for AWS Systems Manager (SSM)',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
customEndpoints: [true],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
placeholder: 'https://ssm.{region}.amazonaws.com',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async authenticate(
|
async authenticate(
|
||||||
|
@ -356,6 +370,8 @@ export class Aws implements ICredentialType {
|
||||||
endpointString = credentials.sqsEndpoint;
|
endpointString = credentials.sqsEndpoint;
|
||||||
} else if (service) {
|
} else if (service) {
|
||||||
endpointString = `https://${service}.${region}.amazonaws.com`;
|
endpointString = `https://${service}.${region}.amazonaws.com`;
|
||||||
|
} else if (service === 'ssm' && credentials.ssmEndpoint) {
|
||||||
|
endpointString = credentials.ssmEndpoint;
|
||||||
}
|
}
|
||||||
endpoint = new URL(endpointString!.replace('{region}', region) + path);
|
endpoint = new URL(endpointString!.replace('{region}', region) + path);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -9,7 +9,7 @@ export class JenkinsApi implements ICredentialType {
|
||||||
|
|
||||||
properties: INodeProperties[] = [
|
properties: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
displayName: 'Jenking Username',
|
displayName: 'Jenkins Username',
|
||||||
name: 'username',
|
name: 'username',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
default: '',
|
default: '',
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type {
|
||||||
|
IAuthenticateGeneric,
|
||||||
|
ICredentialTestRequest,
|
||||||
|
ICredentialType,
|
||||||
|
INodeProperties,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class JiraSoftwareServerPatApi implements ICredentialType {
|
||||||
|
name = 'jiraSoftwareServerPatApi';
|
||||||
|
|
||||||
|
displayName = 'Jira SW Server (PAT) API';
|
||||||
|
|
||||||
|
documentationUrl = 'jira';
|
||||||
|
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Personal Access Token',
|
||||||
|
name: 'personalAccessToken',
|
||||||
|
typeOptions: { password: true },
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Domain',
|
||||||
|
name: 'domain',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
authenticate: IAuthenticateGeneric = {
|
||||||
|
type: 'generic',
|
||||||
|
properties: {
|
||||||
|
headers: {
|
||||||
|
Authorization: '=Bearer {{$credentials.personalAccessToken}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test: ICredentialTestRequest = {
|
||||||
|
request: {
|
||||||
|
baseURL: '={{$credentials?.domain}}',
|
||||||
|
url: '/rest/api/2/project',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
61
packages/nodes-base/credentials/MiroOAuth2Api.credentials.ts
Normal file
61
packages/nodes-base/credentials/MiroOAuth2Api.credentials.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import type { Icon, ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class MiroOAuth2Api implements ICredentialType {
|
||||||
|
name = 'miroOAuth2Api';
|
||||||
|
|
||||||
|
extends = ['oAuth2Api'];
|
||||||
|
|
||||||
|
displayName = 'Miro OAuth2 API';
|
||||||
|
|
||||||
|
documentationUrl = 'miro';
|
||||||
|
|
||||||
|
icon: Icon = 'file:icons/Miro.svg';
|
||||||
|
|
||||||
|
httpRequestNode = {
|
||||||
|
name: 'Miro',
|
||||||
|
docsUrl: 'https://developers.miro.com/reference/overview',
|
||||||
|
apiBaseUrl: 'https://api.miro.com/v2/',
|
||||||
|
};
|
||||||
|
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Grant Type',
|
||||||
|
name: 'grantType',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'authorizationCode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Authorization URL',
|
||||||
|
name: 'authUrl',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'https://miro.com/oauth/authorize',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Access Token URL',
|
||||||
|
name: 'accessTokenUrl',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'https://api.miro.com/v1/oauth/token',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Scope',
|
||||||
|
name: 'scope',
|
||||||
|
type: 'hidden',
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Auth URI Query Parameters',
|
||||||
|
name: 'authQueryParameters',
|
||||||
|
type: 'hidden',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Authentication',
|
||||||
|
name: 'authentication',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'body',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
22
packages/nodes-base/credentials/icons/Miro.svg
Normal file
22
packages/nodes-base/credentials/icons/Miro.svg
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#FFD02F;}
|
||||||
|
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#050038;}
|
||||||
|
</style>
|
||||||
|
<metadata>
|
||||||
|
<sfw xmlns="ns_sfw;">
|
||||||
|
<slices>
|
||||||
|
</slices>
|
||||||
|
<sliceSourceBounds bottomLeftOrigin="true" height="48" width="48" x="175.8" y="-224.2">
|
||||||
|
</sliceSourceBounds>
|
||||||
|
</sfw>
|
||||||
|
</metadata>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M10.4,0h27.2C43.3,0,48,4.7,48,10.4v27.2C48,43.3,43.3,48,37.6,48H10.4C4.7,48,0,43.3,0,37.6V10.4
|
||||||
|
C0,4.7,4.7,0,10.4,0z">
|
||||||
|
</path>
|
||||||
|
<path class="st1" d="M33.3,6h-5.3l4.4,7.7L22.8,6h-5.3l4.8,9.4L12.3,6H7l5.3,12L7,42h5.3l10.1-25.7L17.5,42h5.3l9.7-27.4L28.1,42
|
||||||
|
h5.3L43,12L33.3,6z">
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 915 B |
|
@ -2,6 +2,12 @@ import type { INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
import { appendAttributionOption } from '../../utils/descriptions';
|
import { appendAttributionOption } from '../../utils/descriptions';
|
||||||
|
|
||||||
|
export const placeholder: string = `
|
||||||
|
<!-- Your custom HTML here --->
|
||||||
|
|
||||||
|
|
||||||
|
`.trimStart();
|
||||||
|
|
||||||
export const webhookPath: INodeProperties = {
|
export const webhookPath: INodeProperties = {
|
||||||
displayName: 'Form Path',
|
displayName: 'Form Path',
|
||||||
name: 'path',
|
name: 'path',
|
||||||
|
@ -36,9 +42,9 @@ export const formDescription: INodeProperties = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formFields: INodeProperties = {
|
export const formFields: INodeProperties = {
|
||||||
displayName: 'Form Fields',
|
displayName: 'Form Elements',
|
||||||
name: 'formFields',
|
name: 'formFields',
|
||||||
placeholder: 'Add Form Field',
|
placeholder: 'Add Form Element',
|
||||||
type: 'fixedCollection',
|
type: 'fixedCollection',
|
||||||
default: { values: [{ label: '', fieldType: 'text' }] },
|
default: { values: [{ label: '', fieldType: 'text' }] },
|
||||||
typeOptions: {
|
typeOptions: {
|
||||||
|
@ -60,12 +66,16 @@ export const formFields: INodeProperties = {
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Field Type',
|
displayName: 'Element Type',
|
||||||
name: 'fieldType',
|
name: 'fieldType',
|
||||||
type: 'options',
|
type: 'options',
|
||||||
default: 'text',
|
default: 'text',
|
||||||
description: 'The type of field to add to the form',
|
description: 'The type of field to add to the form',
|
||||||
options: [
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Custom HTML',
|
||||||
|
value: 'html',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Date',
|
name: 'Date',
|
||||||
value: 'date',
|
value: 'date',
|
||||||
|
@ -109,7 +119,7 @@ export const formFields: INodeProperties = {
|
||||||
default: '',
|
default: '',
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
hide: {
|
hide: {
|
||||||
fieldType: ['dropdown', 'date', 'file'],
|
fieldType: ['dropdown', 'date', 'file', 'html'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -158,6 +168,21 @@ export const formFields: INodeProperties = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'HTML Template',
|
||||||
|
name: 'html',
|
||||||
|
typeOptions: {
|
||||||
|
editor: 'htmlEditor',
|
||||||
|
},
|
||||||
|
type: 'string',
|
||||||
|
default: placeholder,
|
||||||
|
description: 'HTML template to render',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
fieldType: ['html'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Multiple Files',
|
displayName: 'Multiple Files',
|
||||||
name: 'multipleFiles',
|
name: 'multipleFiles',
|
||||||
|
@ -190,6 +215,23 @@ export const formFields: INodeProperties = {
|
||||||
name: 'formatDate',
|
name: 'formatDate',
|
||||||
type: 'notice',
|
type: 'notice',
|
||||||
default: '',
|
default: '',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
fieldType: ['date'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName:
|
||||||
|
'Does not accept <code><style></code> <code><script></code> or <code><input></code> tags.',
|
||||||
|
name: 'htmlTips',
|
||||||
|
type: 'notice',
|
||||||
|
default: '',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
fieldType: ['html'],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Required Field',
|
displayName: 'Required Field',
|
||||||
|
@ -198,6 +240,11 @@ export const formFields: INodeProperties = {
|
||||||
default: false,
|
default: false,
|
||||||
description:
|
description:
|
||||||
'Whether to require the user to enter a value for this field before submitting the form',
|
'Whether to require the user to enter a value for this field before submitting the form',
|
||||||
|
displayOptions: {
|
||||||
|
hide: {
|
||||||
|
fieldType: ['html'],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
prepareFormReturnItem,
|
prepareFormReturnItem,
|
||||||
resolveRawData,
|
resolveRawData,
|
||||||
isFormConnected,
|
isFormConnected,
|
||||||
|
sanitizeHtml,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
describe('FormTrigger, parseFormDescription', () => {
|
describe('FormTrigger, parseFormDescription', () => {
|
||||||
|
@ -42,6 +43,29 @@ describe('FormTrigger, parseFormDescription', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FormTrigger, sanitizeHtml', () => {
|
||||||
|
it('should remove forbidden HTML tags', () => {
|
||||||
|
const givenHtml = [
|
||||||
|
{
|
||||||
|
html: '<script>alert("hello world")</script>',
|
||||||
|
expected: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: '<style>body { color: red; }</style>',
|
||||||
|
expected: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: '<input type="text" value="test">',
|
||||||
|
expected: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
givenHtml.forEach(({ html, expected }) => {
|
||||||
|
expect(sanitizeHtml(html)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('FormTrigger, formWebhook', () => {
|
describe('FormTrigger, formWebhook', () => {
|
||||||
const executeFunctions = mock<IWebhookFunctions>();
|
const executeFunctions = mock<IWebhookFunctions>();
|
||||||
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
|
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
|
||||||
|
@ -80,6 +104,12 @@ describe('FormTrigger, formWebhook', () => {
|
||||||
acceptFileTypes: '.pdf,.doc',
|
acceptFileTypes: '.pdf,.doc',
|
||||||
multipleFiles: false,
|
multipleFiles: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldLabel: 'Custom HTML',
|
||||||
|
fieldType: 'html',
|
||||||
|
html: '<div>Test HTML</div>',
|
||||||
|
requiredField: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
|
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
|
||||||
|
@ -134,6 +164,16 @@ describe('FormTrigger, formWebhook', () => {
|
||||||
multipleFiles: '',
|
multipleFiles: '',
|
||||||
placeholder: undefined,
|
placeholder: undefined,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'field-4',
|
||||||
|
errorId: 'error-field-4',
|
||||||
|
label: 'Custom HTML',
|
||||||
|
inputRequired: '',
|
||||||
|
defaultValue: '',
|
||||||
|
placeholder: undefined,
|
||||||
|
html: '<div>Test HTML</div>',
|
||||||
|
isHtml: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
formSubmittedText: 'Your response has been recorded',
|
formSubmittedText: 'Your response has been recorded',
|
||||||
formTitle: 'Test Form',
|
formTitle: 'Test Form',
|
||||||
|
|
|
@ -24,11 +24,16 @@ import { getResolvables } from '../../utils/utilities';
|
||||||
import { WebhookAuthorizationError } from '../Webhook/error';
|
import { WebhookAuthorizationError } from '../Webhook/error';
|
||||||
import { validateWebhookAuthentication } from '../Webhook/utils';
|
import { validateWebhookAuthentication } from '../Webhook/utils';
|
||||||
|
|
||||||
function sanitizeHtml(text: string) {
|
export function sanitizeHtml(text: string) {
|
||||||
return sanitize(text, {
|
return sanitize(text, {
|
||||||
allowedTags: [
|
allowedTags: [
|
||||||
'b',
|
'b',
|
||||||
|
'div',
|
||||||
'i',
|
'i',
|
||||||
|
'iframe',
|
||||||
|
'img',
|
||||||
|
'video',
|
||||||
|
'source',
|
||||||
'em',
|
'em',
|
||||||
'strong',
|
'strong',
|
||||||
'a',
|
'a',
|
||||||
|
@ -48,8 +53,18 @@ function sanitizeHtml(text: string) {
|
||||||
],
|
],
|
||||||
allowedAttributes: {
|
allowedAttributes: {
|
||||||
a: ['href', 'target', 'rel'],
|
a: ['href', 'target', 'rel'],
|
||||||
|
img: ['src', 'alt', 'width', 'height'],
|
||||||
|
video: ['*'],
|
||||||
|
iframe: ['*'],
|
||||||
|
source: ['*'],
|
||||||
|
},
|
||||||
|
transformTags: {
|
||||||
|
iframe: sanitize.simpleTransform('iframe', {
|
||||||
|
sandbox: '',
|
||||||
|
referrerpolicy: 'strict-origin-when-cross-origin',
|
||||||
|
allow: 'fullscreen; autoplay; encrypted-media',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
nonBooleanAttributes: ['*'],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,6 +164,9 @@ export function prepareFormData({
|
||||||
input.selectOptions = fieldOptions.map((e) => e.option);
|
input.selectOptions = fieldOptions.map((e) => e.option);
|
||||||
} else if (fieldType === 'textarea') {
|
} else if (fieldType === 'textarea') {
|
||||||
input.isTextarea = true;
|
input.isTextarea = true;
|
||||||
|
} else if (fieldType === 'html') {
|
||||||
|
input.isHtml = true;
|
||||||
|
input.html = field.html as string;
|
||||||
} else {
|
} else {
|
||||||
input.isInput = true;
|
input.isInput = true;
|
||||||
input.type = fieldType as 'text' | 'number' | 'date' | 'email';
|
input.type = fieldType as 'text' | 'number' | 'date' | 'email';
|
||||||
|
@ -409,7 +427,14 @@ export async function formWebhook(
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = context.getMode() === 'manual' ? 'test' : 'production';
|
const mode = context.getMode() === 'manual' ? 'test' : 'production';
|
||||||
const formFields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter;
|
const formFields = (context.getNodeParameter('formFields.values', []) as FormFieldsParameter).map(
|
||||||
|
(field) => {
|
||||||
|
if (field.fieldType === 'html') {
|
||||||
|
field.html = sanitizeHtml(field.html as string);
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
},
|
||||||
|
);
|
||||||
const method = context.getRequestObject().method;
|
const method = context.getRequestObject().method;
|
||||||
|
|
||||||
checkResponseModeConfiguration(context);
|
checkResponseModeConfiguration(context);
|
||||||
|
|
|
@ -28,6 +28,9 @@ export async function jiraSoftwareCloudApiRequest(
|
||||||
if (jiraVersion === 'server') {
|
if (jiraVersion === 'server') {
|
||||||
domain = (await this.getCredentials('jiraSoftwareServerApi')).domain as string;
|
domain = (await this.getCredentials('jiraSoftwareServerApi')).domain as string;
|
||||||
credentialType = 'jiraSoftwareServerApi';
|
credentialType = 'jiraSoftwareServerApi';
|
||||||
|
} else if (jiraVersion === 'serverPat') {
|
||||||
|
domain = (await this.getCredentials('jiraSoftwareServerPatApi')).domain as string;
|
||||||
|
credentialType = 'jiraSoftwareServerPatApi';
|
||||||
} else {
|
} else {
|
||||||
domain = (await this.getCredentials('jiraSoftwareCloudApi')).domain as string;
|
domain = (await this.getCredentials('jiraSoftwareCloudApi')).domain as string;
|
||||||
credentialType = 'jiraSoftwareCloudApi';
|
credentialType = 'jiraSoftwareCloudApi';
|
||||||
|
@ -233,7 +236,7 @@ export async function getUsers(this: ILoadOptionsFunctions): Promise<INodeProper
|
||||||
const query: IDataObject = { maxResults };
|
const query: IDataObject = { maxResults };
|
||||||
let endpoint = '/api/2/users/search';
|
let endpoint = '/api/2/users/search';
|
||||||
|
|
||||||
if (jiraVersion === 'server') {
|
if (jiraVersion === 'server' || jiraVersion === 'serverPat') {
|
||||||
endpoint = '/api/2/user/search';
|
endpoint = '/api/2/user/search';
|
||||||
query.username = "'";
|
query.username = "'";
|
||||||
}
|
}
|
||||||
|
|
|
@ -67,6 +67,15 @@ export class Jira implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'jiraSoftwareServerPatApi',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
jiraVersion: ['serverPat'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
|
@ -82,6 +91,10 @@ export class Jira implements INodeType {
|
||||||
name: 'Server (Self Hosted)',
|
name: 'Server (Self Hosted)',
|
||||||
value: 'server',
|
value: 'server',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Server Pat (Self Hosted)',
|
||||||
|
value: 'serverPat',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
default: 'cloud',
|
default: 'cloud',
|
||||||
},
|
},
|
||||||
|
@ -139,7 +152,7 @@ export class Jira implements INodeType {
|
||||||
let endpoint = '';
|
let endpoint = '';
|
||||||
let projects;
|
let projects;
|
||||||
|
|
||||||
if (jiraVersion === 'server') {
|
if (jiraVersion === 'server' || jiraVersion === 'serverPat') {
|
||||||
endpoint = '/api/2/project';
|
endpoint = '/api/2/project';
|
||||||
projects = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'GET');
|
projects = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'GET');
|
||||||
} else {
|
} else {
|
||||||
|
@ -276,8 +289,12 @@ export class Jira implements INodeType {
|
||||||
async getCustomFields(this: ILoadOptionsFunctions): Promise<INodeListSearchResult> {
|
async getCustomFields(this: ILoadOptionsFunctions): Promise<INodeListSearchResult> {
|
||||||
const returnData: INodeListSearchItems[] = [];
|
const returnData: INodeListSearchItems[] = [];
|
||||||
const operation = this.getCurrentNodeParameter('operation') as string;
|
const operation = this.getCurrentNodeParameter('operation') as string;
|
||||||
|
const jiraVersion = this.getNodeParameter('jiraVersion', 0) as string;
|
||||||
|
|
||||||
let projectId: string;
|
let projectId: string;
|
||||||
let issueTypeId: string;
|
let issueTypeId: string;
|
||||||
|
let issueId: string = ''; // /editmeta endpoint requires issueId
|
||||||
|
|
||||||
if (operation === 'create') {
|
if (operation === 'create') {
|
||||||
projectId = this.getCurrentNodeParameter('project', { extractValue: true }) as string;
|
projectId = this.getCurrentNodeParameter('project', { extractValue: true }) as string;
|
||||||
issueTypeId = this.getCurrentNodeParameter('issueType', { extractValue: true }) as string;
|
issueTypeId = this.getCurrentNodeParameter('issueType', { extractValue: true }) as string;
|
||||||
|
@ -292,6 +309,26 @@ export class Jira implements INodeType {
|
||||||
);
|
);
|
||||||
projectId = res.fields.project.id;
|
projectId = res.fields.project.id;
|
||||||
issueTypeId = res.fields.issuetype.id;
|
issueTypeId = res.fields.issuetype.id;
|
||||||
|
issueId = res.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jiraVersion === 'server' && operation === 'update' && issueId) {
|
||||||
|
// https://developer.atlassian.com/server/jira/platform/jira-rest-api-example-edit-issues-6291632/?utm_source=chatgpt.com
|
||||||
|
const { fields } = await jiraSoftwareCloudApiRequest.call(
|
||||||
|
this,
|
||||||
|
`/api/2/issue/${issueId}/editmeta`,
|
||||||
|
'GET',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const field of Object.keys(fields || {})) {
|
||||||
|
if (field.startsWith('customfield_')) {
|
||||||
|
returnData.push({
|
||||||
|
name: fields[field].name,
|
||||||
|
value: field,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { results: returnData };
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await jiraSoftwareCloudApiRequest.call(
|
const res = await jiraSoftwareCloudApiRequest.call(
|
||||||
|
@ -478,7 +515,7 @@ export class Jira implements INodeType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (additionalFields.assignee) {
|
if (additionalFields.assignee) {
|
||||||
if (jiraVersion === 'server') {
|
if (jiraVersion === 'server' || jiraVersion === 'serverPat') {
|
||||||
fields.assignee = {
|
fields.assignee = {
|
||||||
name: additionalFields.assignee as string,
|
name: additionalFields.assignee as string,
|
||||||
};
|
};
|
||||||
|
@ -489,7 +526,7 @@ export class Jira implements INodeType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (additionalFields.reporter) {
|
if (additionalFields.reporter) {
|
||||||
if (jiraVersion === 'server') {
|
if (jiraVersion === 'server' || jiraVersion === 'serverPat') {
|
||||||
fields.reporter = {
|
fields.reporter = {
|
||||||
name: additionalFields.reporter as string,
|
name: additionalFields.reporter as string,
|
||||||
};
|
};
|
||||||
|
@ -608,7 +645,7 @@ export class Jira implements INodeType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (updateFields.assignee) {
|
if (updateFields.assignee) {
|
||||||
if (jiraVersion === 'server') {
|
if (jiraVersion === 'server' || jiraVersion === 'serverPat') {
|
||||||
fields.assignee = {
|
fields.assignee = {
|
||||||
name: updateFields.assignee as string,
|
name: updateFields.assignee as string,
|
||||||
};
|
};
|
||||||
|
@ -619,7 +656,7 @@ export class Jira implements INodeType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updateFields.reporter) {
|
if (updateFields.reporter) {
|
||||||
if (jiraVersion === 'server') {
|
if (jiraVersion === 'server' || jiraVersion === 'serverPat') {
|
||||||
fields.reporter = {
|
fields.reporter = {
|
||||||
name: updateFields.reporter as string,
|
name: updateFields.reporter as string,
|
||||||
};
|
};
|
||||||
|
@ -1001,7 +1038,8 @@ export class Jira implements INodeType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resource === 'issueAttachment') {
|
if (resource === 'issueAttachment') {
|
||||||
const apiVersion = jiraVersion === 'server' ? '2' : ('3' as string);
|
const apiVersion =
|
||||||
|
jiraVersion === 'server' || jiraVersion === 'serverPat' ? '2' : ('3' as string);
|
||||||
|
|
||||||
//https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-attachments/#api-rest-api-3-issue-issueidorkey-attachments-post
|
//https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-attachments/#api-rest-api-3-issue-issueidorkey-attachments-post
|
||||||
if (operation === 'add') {
|
if (operation === 'add') {
|
||||||
|
@ -1159,7 +1197,8 @@ export class Jira implements INodeType {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resource === 'issueComment') {
|
if (resource === 'issueComment') {
|
||||||
let apiVersion = jiraVersion === 'server' ? '2' : ('3' as string);
|
let apiVersion =
|
||||||
|
jiraVersion === 'server' || jiraVersion === 'serverPat' ? '2' : ('3' as string);
|
||||||
|
|
||||||
//https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-comments/#api-rest-api-3-issue-issueidorkey-comment-post
|
//https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-comments/#api-rest-api-3-issue-issueidorkey-comment-post
|
||||||
if (operation === 'add') {
|
if (operation === 'add') {
|
||||||
|
@ -1181,7 +1220,7 @@ export class Jira implements INodeType {
|
||||||
Object.assign(body, options);
|
Object.assign(body, options);
|
||||||
if (!jsonParameters) {
|
if (!jsonParameters) {
|
||||||
const comment = this.getNodeParameter('comment', i) as string;
|
const comment = this.getNodeParameter('comment', i) as string;
|
||||||
if (jiraVersion === 'server' || options.wikiMarkup) {
|
if (jiraVersion === 'server' || jiraVersion === 'serverPat' || options.wikiMarkup) {
|
||||||
Object.assign(body, { body: comment });
|
Object.assign(body, { body: comment });
|
||||||
} else {
|
} else {
|
||||||
Object.assign(body, {
|
Object.assign(body, {
|
||||||
|
@ -1332,7 +1371,7 @@ export class Jira implements INodeType {
|
||||||
Object.assign(qs, options);
|
Object.assign(qs, options);
|
||||||
if (!jsonParameters) {
|
if (!jsonParameters) {
|
||||||
const comment = this.getNodeParameter('comment', i) as string;
|
const comment = this.getNodeParameter('comment', i) as string;
|
||||||
if (jiraVersion === 'server' || options.wikiMarkup) {
|
if (jiraVersion === 'server' || jiraVersion === 'serverPat' || options.wikiMarkup) {
|
||||||
Object.assign(body, { body: comment });
|
Object.assign(body, { body: comment });
|
||||||
} else {
|
} else {
|
||||||
Object.assign(body, {
|
Object.assign(body, {
|
||||||
|
@ -1383,7 +1422,8 @@ export class Jira implements INodeType {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resource === 'user') {
|
if (resource === 'user') {
|
||||||
const apiVersion = jiraVersion === 'server' ? '2' : ('3' as string);
|
const apiVersion =
|
||||||
|
jiraVersion === 'server' || jiraVersion === 'serverPat' ? '2' : ('3' as string);
|
||||||
|
|
||||||
if (operation === 'create') {
|
if (operation === 'create') {
|
||||||
// https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-users/#api-rest-api-3-user-post
|
// https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-users/#api-rest-api-3-user-post
|
||||||
|
|
|
@ -45,6 +45,16 @@ export class JiraTrigger implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Credentials to Connect to Jira',
|
||||||
|
name: 'jiraSoftwareServerPatApi',
|
||||||
|
required: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
jiraVersion: ['serverPat'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
|
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
|
||||||
name: 'httpQueryAuth',
|
name: 'httpQueryAuth',
|
||||||
|
@ -87,6 +97,10 @@ export class JiraTrigger implements INodeType {
|
||||||
name: 'Server (Self Hosted)',
|
name: 'Server (Self Hosted)',
|
||||||
value: 'server',
|
value: 'server',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Server (Pat) (Self Hosted)',
|
||||||
|
value: 'serverPat',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
default: 'cloud',
|
default: 'cloud',
|
||||||
},
|
},
|
||||||
|
|
126
packages/nodes-base/nodes/Jira/test/node.methods.test.ts
Normal file
126
packages/nodes-base/nodes/Jira/test/node.methods.test.ts
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import type { MockProxy } from 'jest-mock-extended';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { IHttpRequestMethods, ILoadOptionsFunctions } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { Jira } from '../Jira.node';
|
||||||
|
|
||||||
|
const ISSUE_KEY = 'KEY-1';
|
||||||
|
|
||||||
|
jest.mock('../GenericFunctions', () => {
|
||||||
|
const originalModule = jest.requireActual('../GenericFunctions');
|
||||||
|
return {
|
||||||
|
...originalModule,
|
||||||
|
jiraSoftwareCloudApiRequest: jest.fn(async function (
|
||||||
|
endpoint: string,
|
||||||
|
method: IHttpRequestMethods,
|
||||||
|
) {
|
||||||
|
if (method === 'GET' && endpoint === `/api/2/issue/${ISSUE_KEY}`) {
|
||||||
|
return {
|
||||||
|
id: 10000,
|
||||||
|
fields: {
|
||||||
|
project: {
|
||||||
|
id: 10001,
|
||||||
|
},
|
||||||
|
issuetype: {
|
||||||
|
id: 10002,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (method === 'GET' && endpoint === '/api/2/issue/10000/editmeta') {
|
||||||
|
return {
|
||||||
|
fields: {
|
||||||
|
customfield_123: {
|
||||||
|
name: 'Field 123',
|
||||||
|
},
|
||||||
|
customfield_456: {
|
||||||
|
name: 'Field 456',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (
|
||||||
|
method === 'GET' &&
|
||||||
|
endpoint ===
|
||||||
|
'/api/2/issue/createmeta?projectIds=10001&issueTypeIds=10002&expand=projects.issuetypes.fields'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: 10001,
|
||||||
|
issuetypes: [
|
||||||
|
{
|
||||||
|
id: 10002,
|
||||||
|
fields: {
|
||||||
|
customfield_abc: {
|
||||||
|
name: 'Field ABC',
|
||||||
|
schema: { customId: 'customfield_abc' },
|
||||||
|
fieldId: 'customfield_abc',
|
||||||
|
},
|
||||||
|
customfield_def: {
|
||||||
|
name: 'Field DEF',
|
||||||
|
schema: { customId: 'customfield_def' },
|
||||||
|
fieldId: 'customfield_def',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Jira Node, methods', () => {
|
||||||
|
let jira: Jira;
|
||||||
|
let loadOptionsFunctions: MockProxy<ILoadOptionsFunctions>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jira = new Jira();
|
||||||
|
loadOptionsFunctions = mock<ILoadOptionsFunctions>();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listSearch.getCustomFields', () => {
|
||||||
|
it('should call correct endpoint and return custom fields for server version', async () => {
|
||||||
|
loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce('update');
|
||||||
|
loadOptionsFunctions.getNodeParameter.mockReturnValue('server');
|
||||||
|
loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce(ISSUE_KEY);
|
||||||
|
|
||||||
|
const { results } = await jira.methods.listSearch.getCustomFields.call(
|
||||||
|
loadOptionsFunctions as ILoadOptionsFunctions,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Field 123',
|
||||||
|
value: 'customfield_123',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Field 456',
|
||||||
|
value: 'customfield_456',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call correct endpoint and return custom fields for cloud version', async () => {
|
||||||
|
loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce('update');
|
||||||
|
loadOptionsFunctions.getNodeParameter.mockReturnValue('cloud');
|
||||||
|
loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce(ISSUE_KEY);
|
||||||
|
|
||||||
|
const { results } = await jira.methods.listSearch.getCustomFields.call(
|
||||||
|
loadOptionsFunctions as ILoadOptionsFunctions,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Field ABC',
|
||||||
|
value: 'customfield_abc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Field DEF',
|
||||||
|
value: 'customfield_def',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-nodes-base",
|
"name": "n8n-nodes-base",
|
||||||
"version": "1.75.0",
|
"version": "1.76.0",
|
||||||
"description": "Base nodes of n8n",
|
"description": "Base nodes of n8n",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -181,6 +181,7 @@
|
||||||
"dist/credentials/JenkinsApi.credentials.js",
|
"dist/credentials/JenkinsApi.credentials.js",
|
||||||
"dist/credentials/JiraSoftwareCloudApi.credentials.js",
|
"dist/credentials/JiraSoftwareCloudApi.credentials.js",
|
||||||
"dist/credentials/JiraSoftwareServerApi.credentials.js",
|
"dist/credentials/JiraSoftwareServerApi.credentials.js",
|
||||||
|
"dist/credentials/JiraSoftwareServerPatApi.credentials.js",
|
||||||
"dist/credentials/JotFormApi.credentials.js",
|
"dist/credentials/JotFormApi.credentials.js",
|
||||||
"dist/credentials/JwtAuth.credentials.js",
|
"dist/credentials/JwtAuth.credentials.js",
|
||||||
"dist/credentials/Kafka.credentials.js",
|
"dist/credentials/Kafka.credentials.js",
|
||||||
|
@ -231,6 +232,7 @@
|
||||||
"dist/credentials/MicrosoftToDoOAuth2Api.credentials.js",
|
"dist/credentials/MicrosoftToDoOAuth2Api.credentials.js",
|
||||||
"dist/credentials/MindeeInvoiceApi.credentials.js",
|
"dist/credentials/MindeeInvoiceApi.credentials.js",
|
||||||
"dist/credentials/MindeeReceiptApi.credentials.js",
|
"dist/credentials/MindeeReceiptApi.credentials.js",
|
||||||
|
"dist/credentials/MiroOAuth2Api.credentials.js",
|
||||||
"dist/credentials/MispApi.credentials.js",
|
"dist/credentials/MispApi.credentials.js",
|
||||||
"dist/credentials/MistApi.credentials.js",
|
"dist/credentials/MistApi.credentials.js",
|
||||||
"dist/credentials/MoceanApi.credentials.js",
|
"dist/credentials/MoceanApi.credentials.js",
|
||||||
|
|
|
@ -1 +1,6 @@
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
// Disable task runners until we have fixed the "run test workflows" test
|
||||||
|
// to mock the Code Node execution
|
||||||
|
process.env.N8N_RUNNERS_ENABLED = 'false';
|
||||||
|
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-workflow",
|
"name": "n8n-workflow",
|
||||||
"version": "1.74.0",
|
"version": "1.75.0",
|
||||||
"description": "Workflow base code of n8n",
|
"description": "Workflow base code of n8n",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
|
|
|
@ -36,6 +36,7 @@ export function augmentArray<T>(data: T[]): T[] {
|
||||||
return Reflect.deleteProperty(getData(), key);
|
return Reflect.deleteProperty(getData(), key);
|
||||||
},
|
},
|
||||||
get(target, key: string, receiver): unknown {
|
get(target, key: string, receiver): unknown {
|
||||||
|
if (key === 'constructor') return Array;
|
||||||
const value = Reflect.get(newData ?? target, key, receiver) as unknown;
|
const value = Reflect.get(newData ?? target, key, receiver) as unknown;
|
||||||
const newValue = augment(value);
|
const newValue = augment(value);
|
||||||
if (newValue !== value) {
|
if (newValue !== value) {
|
||||||
|
@ -83,6 +84,8 @@ export function augmentObject<T extends object>(data: T): T {
|
||||||
|
|
||||||
const proxy = new Proxy(data, {
|
const proxy = new Proxy(data, {
|
||||||
get(target, key: string, receiver): unknown {
|
get(target, key: string, receiver): unknown {
|
||||||
|
if (key === 'constructor') return Object;
|
||||||
|
|
||||||
if (deletedProperties.has(key)) {
|
if (deletedProperties.has(key)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2684,6 +2684,7 @@ export type FormFieldsParameter = Array<{
|
||||||
multipleFiles?: boolean;
|
multipleFiles?: boolean;
|
||||||
acceptFileTypes?: string;
|
acceptFileTypes?: string;
|
||||||
formatDate?: string;
|
formatDate?: string;
|
||||||
|
html?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,8 @@ describe('AugmentObject', () => {
|
||||||
|
|
||||||
const augmentedObject = augmentArray(originalObject);
|
const augmentedObject = augmentArray(originalObject);
|
||||||
|
|
||||||
|
expect(augmentedObject.constructor.name).toEqual('Array');
|
||||||
|
|
||||||
expect(augmentedObject.push(5)).toEqual(6);
|
expect(augmentedObject.push(5)).toEqual(6);
|
||||||
expect(augmentedObject).toEqual([1, 2, 3, 4, null, 5]);
|
expect(augmentedObject).toEqual([1, 2, 3, 4, null, 5]);
|
||||||
expect(originalObject).toEqual(copyOriginal);
|
expect(originalObject).toEqual(copyOriginal);
|
||||||
|
@ -207,6 +209,8 @@ describe('AugmentObject', () => {
|
||||||
|
|
||||||
const augmentedObject = augmentObject(originalObject);
|
const augmentedObject = augmentObject(originalObject);
|
||||||
|
|
||||||
|
expect(augmentedObject.constructor.name).toEqual('Object');
|
||||||
|
|
||||||
augmentedObject[1] = 911;
|
augmentedObject[1] = 911;
|
||||||
expect(originalObject[1]).toEqual(11);
|
expect(originalObject[1]).toEqual(11);
|
||||||
expect(augmentedObject[1]).toEqual(911);
|
expect(augmentedObject[1]).toEqual(911);
|
||||||
|
@ -589,5 +593,29 @@ describe('AugmentObject', () => {
|
||||||
delete augmentedObject.toString;
|
delete augmentedObject.toString;
|
||||||
expect(augmentedObject.toString).toBeUndefined();
|
expect(augmentedObject.toString).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should handle constructor property correctly', () => {
|
||||||
|
const originalObject: any = {
|
||||||
|
a: {
|
||||||
|
b: {
|
||||||
|
c: {
|
||||||
|
d: '4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const augmentedObject = augmentObject(originalObject);
|
||||||
|
|
||||||
|
expect(augmentedObject.constructor.name).toEqual('Object');
|
||||||
|
expect(augmentedObject.a.constructor.name).toEqual('Object');
|
||||||
|
expect(augmentedObject.a.b.constructor.name).toEqual('Object');
|
||||||
|
expect(augmentedObject.a.b.c.constructor.name).toEqual('Object');
|
||||||
|
|
||||||
|
augmentedObject.constructor = {};
|
||||||
|
expect(augmentedObject.constructor.name).toEqual('Object');
|
||||||
|
|
||||||
|
delete augmentedObject.constructor;
|
||||||
|
expect(augmentedObject.constructor.name).toEqual('Object');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue