first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.wait",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Core Nodes"],
|
||||
"resources": {
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/"
|
||||
}
|
||||
],
|
||||
"generic": [
|
||||
{
|
||||
"label": "How to get started with CRM automation (with 3 no-code workflow ideas",
|
||||
"icon": "👥",
|
||||
"url": "https://n8n.io/blog/how-to-get-started-with-crm-automation-and-no-code-workflow-ideas/"
|
||||
},
|
||||
{
|
||||
"label": "7 no-code workflow automations for Amazon Web Services",
|
||||
"url": "https://n8n.io/blog/aws-workflow-automation/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"alias": ["pause", "sleep", "delay", "timeout"],
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Helpers", "Flow"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeTypeDescription,
|
||||
INodeProperties,
|
||||
IDisplayOptions,
|
||||
IWebhookFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
WAIT_INDEFINITELY,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
tryToParseDateTime,
|
||||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { validateWaitAmount, validateWaitUnit } from './validation';
|
||||
import { updateDisplayOptions } from '../../utils/utilities';
|
||||
import {
|
||||
formDescription,
|
||||
formFields,
|
||||
respondWithOptions,
|
||||
formRespondMode,
|
||||
formTitle,
|
||||
appendAttributionToForm,
|
||||
} from '../Form/common.descriptions';
|
||||
import { formWebhook } from '../Form/utils/utils';
|
||||
import {
|
||||
authenticationProperty,
|
||||
credentialsProperty,
|
||||
defaultWebhookDescription,
|
||||
httpMethodsProperty,
|
||||
optionsProperty,
|
||||
responseBinaryPropertyNameProperty,
|
||||
responseCodeProperty,
|
||||
responseDataProperty,
|
||||
responseModeProperty,
|
||||
} from '../Webhook/description';
|
||||
import { Webhook } from '../Webhook/Webhook.node';
|
||||
|
||||
const toWaitAmount: INodeProperties = {
|
||||
displayName: 'Wait Amount',
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 2,
|
||||
},
|
||||
default: 1,
|
||||
description: 'The time to wait',
|
||||
validateType: 'number',
|
||||
};
|
||||
|
||||
const unitSelector: INodeProperties = {
|
||||
displayName: 'Wait Unit',
|
||||
name: 'unit',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Seconds',
|
||||
value: 'seconds',
|
||||
},
|
||||
{
|
||||
name: 'Minutes',
|
||||
value: 'minutes',
|
||||
},
|
||||
{
|
||||
name: 'Hours',
|
||||
value: 'hours',
|
||||
},
|
||||
{
|
||||
name: 'Days',
|
||||
value: 'days',
|
||||
},
|
||||
],
|
||||
default: 'hours',
|
||||
description: 'The time unit of the Wait Amount value',
|
||||
};
|
||||
|
||||
const waitTimeProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Limit Wait Time',
|
||||
name: 'limitWaitTime',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to limit the time this node should wait for a user response before execution resumes',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resume: ['webhook', 'form'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Limit Type',
|
||||
name: 'limitType',
|
||||
type: 'options',
|
||||
default: 'afterTimeInterval',
|
||||
description:
|
||||
'Sets the condition for the execution to resume. Can be a specified date or after some time.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
limitWaitTime: [true],
|
||||
resume: ['webhook', 'form'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'After Time Interval',
|
||||
description: 'Waits for a certain amount of time',
|
||||
value: 'afterTimeInterval',
|
||||
},
|
||||
{
|
||||
name: 'At Specified Time',
|
||||
description: 'Waits until the set date and time to continue',
|
||||
value: 'atSpecifiedTime',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Amount',
|
||||
name: 'resumeAmount',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
limitType: ['afterTimeInterval'],
|
||||
limitWaitTime: [true],
|
||||
resume: ['webhook', 'form'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
numberPrecision: 2,
|
||||
},
|
||||
default: 1,
|
||||
description: 'The time to wait',
|
||||
},
|
||||
{
|
||||
displayName: 'Unit',
|
||||
name: 'resumeUnit',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
limitType: ['afterTimeInterval'],
|
||||
limitWaitTime: [true],
|
||||
resume: ['webhook', 'form'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Seconds',
|
||||
value: 'seconds',
|
||||
},
|
||||
{
|
||||
name: 'Minutes',
|
||||
value: 'minutes',
|
||||
},
|
||||
{
|
||||
name: 'Hours',
|
||||
value: 'hours',
|
||||
},
|
||||
{
|
||||
name: 'Days',
|
||||
value: 'days',
|
||||
},
|
||||
],
|
||||
default: 'hours',
|
||||
description: 'Unit of the interval value',
|
||||
},
|
||||
{
|
||||
displayName: 'Max Date and Time',
|
||||
name: 'maxDateAndTime',
|
||||
type: 'dateTime',
|
||||
displayOptions: {
|
||||
show: {
|
||||
limitType: ['atSpecifiedTime'],
|
||||
limitWaitTime: [true],
|
||||
resume: ['webhook', 'form'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Continue execution after the specified date and time',
|
||||
},
|
||||
];
|
||||
|
||||
const webhookSuffix: INodeProperties = {
|
||||
displayName: 'Webhook Suffix',
|
||||
name: 'webhookSuffix',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'webhook',
|
||||
noDataExpression: true,
|
||||
description:
|
||||
'This suffix path will be appended to the restart URL. Helpful when using multiple wait nodes.',
|
||||
};
|
||||
|
||||
const displayOnWebhook: IDisplayOptions = {
|
||||
show: {
|
||||
resume: ['webhook'],
|
||||
},
|
||||
};
|
||||
|
||||
const displayOnFormSubmission = {
|
||||
show: {
|
||||
resume: ['form'],
|
||||
},
|
||||
};
|
||||
|
||||
const onFormSubmitProperties = updateDisplayOptions(displayOnFormSubmission, [
|
||||
formTitle,
|
||||
formDescription,
|
||||
formFields,
|
||||
formRespondMode,
|
||||
]);
|
||||
|
||||
const onWebhookCallProperties = updateDisplayOptions(displayOnWebhook, [
|
||||
{
|
||||
...httpMethodsProperty,
|
||||
description: 'The HTTP method of the Webhook call',
|
||||
},
|
||||
responseCodeProperty,
|
||||
responseModeProperty,
|
||||
responseDataProperty,
|
||||
responseBinaryPropertyNameProperty,
|
||||
]);
|
||||
|
||||
const webhookPath = '={{$parameter["options"]["webhookSuffix"] || ""}}';
|
||||
|
||||
const waitingTooltip = (
|
||||
parameters: { resume: string; options?: Record<string, string> },
|
||||
resumeUrl: string,
|
||||
formResumeUrl: string,
|
||||
) => {
|
||||
const resume = parameters.resume;
|
||||
|
||||
if (['webhook', 'form'].includes(resume as string)) {
|
||||
const { webhookSuffix } = (parameters.options ?? {}) as { webhookSuffix: string };
|
||||
const suffix = webhookSuffix && typeof webhookSuffix !== 'object' ? `/${webhookSuffix}` : '';
|
||||
|
||||
let message = '';
|
||||
const url = `${resume === 'form' ? formResumeUrl : resumeUrl}${suffix}`;
|
||||
|
||||
if (resume === 'form') {
|
||||
message = 'Execution will continue when form is submitted on ';
|
||||
}
|
||||
|
||||
if (resume === 'webhook') {
|
||||
message = 'Execution will continue when webhook is received on ';
|
||||
}
|
||||
|
||||
return `${message}<a href="${url}" target="_blank">${url}</a>`;
|
||||
}
|
||||
|
||||
return 'Execution will continue when wait time is over';
|
||||
};
|
||||
|
||||
export class Wait extends Webhook {
|
||||
authPropertyName = 'incomingAuthentication';
|
||||
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Wait',
|
||||
name: 'wait',
|
||||
icon: 'fa:pause-circle',
|
||||
iconColor: 'crimson',
|
||||
group: ['organization'],
|
||||
version: [1, 1.1],
|
||||
description: 'Wait before continue with execution',
|
||||
defaults: {
|
||||
name: 'Wait',
|
||||
color: '#804050',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: credentialsProperty(this.authPropertyName),
|
||||
waitingNodeTooltip: `={{ (${waitingTooltip})($parameter, $execution.resumeUrl, $execution.resumeFormUrl) }}`,
|
||||
webhooks: [
|
||||
{
|
||||
...defaultWebhookDescription,
|
||||
responseData: '={{$parameter["responseData"]}}',
|
||||
path: webhookPath,
|
||||
restartWebhook: true,
|
||||
},
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'GET',
|
||||
responseMode: 'onReceived',
|
||||
path: webhookPath,
|
||||
restartWebhook: true,
|
||||
isFullPath: true,
|
||||
nodeType: 'form',
|
||||
},
|
||||
{
|
||||
name: 'default',
|
||||
httpMethod: 'POST',
|
||||
responseMode: '={{$parameter["responseMode"]}}',
|
||||
responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}',
|
||||
path: webhookPath,
|
||||
restartWebhook: true,
|
||||
isFullPath: true,
|
||||
nodeType: 'form',
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resume',
|
||||
name: 'resume',
|
||||
type: 'options',
|
||||
builderHint: {
|
||||
message:
|
||||
'For user approval workflows, consider using nodes with operation: "sendAndWait" (e.g., email, Slack) instead of Wait node. If using "webhook", the URL will be generated at runtime and can be referenced with {{ $execution.resumeUrl }}.',
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'After Time Interval',
|
||||
value: 'timeInterval',
|
||||
description: 'Waits for a certain amount of time',
|
||||
},
|
||||
{
|
||||
name: 'At Specified Time',
|
||||
value: 'specificTime',
|
||||
description: 'Waits until a specific date and time to continue',
|
||||
},
|
||||
{
|
||||
name: 'On Webhook Call',
|
||||
value: 'webhook',
|
||||
description: 'Waits for a webhook call before continuing',
|
||||
},
|
||||
{
|
||||
name: 'On Form Submitted',
|
||||
value: 'form',
|
||||
description: 'Waits for a form submission before continuing',
|
||||
},
|
||||
],
|
||||
default: 'timeInterval',
|
||||
description: 'Determines the waiting mode to use before the workflow continues',
|
||||
},
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'incomingAuthentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Basic Auth',
|
||||
value: 'basicAuth',
|
||||
},
|
||||
{
|
||||
name: 'None',
|
||||
value: 'none',
|
||||
},
|
||||
],
|
||||
default: 'none',
|
||||
description:
|
||||
'If and how incoming resume-webhook-requests to $execution.resumeFormUrl should be authenticated for additional security',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resume: ['form'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...authenticationProperty(this.authPropertyName),
|
||||
description:
|
||||
'If and how incoming resume-webhook-requests to $execution.resumeUrl should be authenticated for additional security',
|
||||
displayOptions: displayOnWebhook,
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// resume:specificTime
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Date and Time',
|
||||
name: 'dateTime',
|
||||
type: 'dateTime',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resume: ['specificTime'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The date and time to wait for before continuing',
|
||||
required: true,
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// resume:timeInterval
|
||||
// ----------------------------------
|
||||
{
|
||||
...toWaitAmount,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resume: ['timeInterval'],
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...toWaitAmount,
|
||||
default: 5,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resume: ['timeInterval'],
|
||||
},
|
||||
hide: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...unitSelector,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resume: ['timeInterval'],
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...unitSelector,
|
||||
default: 'seconds',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resume: ['timeInterval'],
|
||||
},
|
||||
hide: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// resume:webhook & form
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName:
|
||||
'The webhook URL will be generated at run time. It can be referenced with the <strong>$execution.resumeUrl</strong> variable. Send it somewhere before getting to this node. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/?utm_source=n8n_app&utm_medium=node_settings_modal-credential_link&utm_campaign=n8n-nodes-base.wait" target="_blank">More info</a>',
|
||||
name: 'webhookNotice',
|
||||
type: 'notice',
|
||||
displayOptions: displayOnWebhook,
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'The form url will be generated at run time. It can be referenced with the <strong>$execution.resumeFormUrl</strong> variable. Send it somewhere before getting to this node. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/?utm_source=n8n_app&utm_medium=node_settings_modal-credential_link&utm_campaign=n8n-nodes-base.wait" target="_blank">More info</a>',
|
||||
name: 'formNotice',
|
||||
type: 'notice',
|
||||
displayOptions: displayOnFormSubmission,
|
||||
default: '',
|
||||
},
|
||||
...onFormSubmitProperties,
|
||||
...onWebhookCallProperties,
|
||||
...waitTimeProperties,
|
||||
{
|
||||
...optionsProperty,
|
||||
displayOptions: displayOnWebhook,
|
||||
options: [...(optionsProperty.options as INodeProperties[]), webhookSuffix],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resume: ['form'],
|
||||
},
|
||||
hide: {
|
||||
responseMode: ['responseNode'],
|
||||
},
|
||||
},
|
||||
options: [appendAttributionToForm, respondWithOptions, webhookSuffix],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resume: ['form'],
|
||||
},
|
||||
hide: {
|
||||
responseMode: ['onReceived', 'lastNode'],
|
||||
},
|
||||
},
|
||||
options: [appendAttributionToForm, webhookSuffix],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async webhook(context: IWebhookFunctions) {
|
||||
const resume = context.getNodeParameter('resume', 0) as string;
|
||||
if (resume === 'form') return await formWebhook(context, this.authPropertyName);
|
||||
return await super.webhook(context);
|
||||
}
|
||||
|
||||
async execute(context: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const resume = context.getNodeParameter('resume', 0) as string;
|
||||
|
||||
if (['webhook', 'form'].includes(resume)) {
|
||||
let hasFormTrigger = false;
|
||||
|
||||
if (resume === 'form') {
|
||||
const parentNodes = context.getParentNodes(context.getNode().name);
|
||||
hasFormTrigger = parentNodes.some((node) => node.type === FORM_TRIGGER_NODE_TYPE);
|
||||
}
|
||||
|
||||
const returnData = await this.configureAndPutToWait(context);
|
||||
|
||||
if (resume === 'form' && hasFormTrigger) {
|
||||
context.sendResponse({
|
||||
headers: {
|
||||
location: context.evaluateExpression('{{ $execution.resumeFormUrl }}', 0),
|
||||
},
|
||||
statusCode: 307,
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
let waitTill: Date;
|
||||
if (resume === 'timeInterval') {
|
||||
const unit = context.getNodeParameter('unit', 0);
|
||||
|
||||
if (!validateWaitUnit(unit)) {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
"Invalid wait unit. Valid units are 'seconds', 'minutes', 'hours', or 'days'.",
|
||||
);
|
||||
}
|
||||
|
||||
let waitAmount = context.getNodeParameter('amount', 0);
|
||||
|
||||
if (!validateWaitAmount(waitAmount)) {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
'Invalid wait amount. Please enter a number that is 0 or greater.',
|
||||
);
|
||||
}
|
||||
|
||||
if (unit === 'minutes') {
|
||||
waitAmount *= 60;
|
||||
}
|
||||
if (unit === 'hours') {
|
||||
waitAmount *= 60 * 60;
|
||||
}
|
||||
if (unit === 'days') {
|
||||
waitAmount *= 60 * 60 * 24;
|
||||
}
|
||||
|
||||
waitAmount *= 1000;
|
||||
|
||||
// Timezone does not change relative dates, since they are just
|
||||
// a number of seconds added to the current timestamp
|
||||
waitTill = new Date(new Date().getTime() + waitAmount);
|
||||
} else {
|
||||
try {
|
||||
const dateTimeStrRaw = context.getNodeParameter('dateTime', 0);
|
||||
const parsedDateTime = tryToParseDateTime(dateTimeStrRaw, context.getTimezone());
|
||||
|
||||
waitTill = parsedDateTime.toUTC().toJSDate();
|
||||
} catch (e) {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
'Cannot put execution to wait because `dateTime` parameter is not a valid date. Please pick a specific date and time to wait until.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const waitValue = Math.max(waitTill.getTime() - new Date().getTime(), 0);
|
||||
|
||||
if (waitValue < 65000) {
|
||||
// If wait time is shorter than 65 seconds leave execution active because
|
||||
// we just check the database every 60 seconds.
|
||||
return await new Promise((resolve, _reject) => {
|
||||
const timer = setTimeout(() => resolve([context.getInputData()]), waitValue);
|
||||
context.onExecutionCancellation(() => {
|
||||
clearTimeout(timer);
|
||||
resolve([context.getInputData()]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// If longer than 65 seconds put execution to wait
|
||||
return await this.putToWait(context, waitTill);
|
||||
}
|
||||
|
||||
private async configureAndPutToWait(context: IExecuteFunctions) {
|
||||
let waitTill = WAIT_INDEFINITELY;
|
||||
const limitWaitTime = context.getNodeParameter('limitWaitTime', 0);
|
||||
|
||||
if (limitWaitTime === true) {
|
||||
const limitType = context.getNodeParameter('limitType', 0);
|
||||
|
||||
if (limitType === 'afterTimeInterval') {
|
||||
let waitAmount = context.getNodeParameter('resumeAmount', 0) as number;
|
||||
const resumeUnit = context.getNodeParameter('resumeUnit', 0);
|
||||
|
||||
if (resumeUnit === 'minutes') {
|
||||
waitAmount *= 60;
|
||||
}
|
||||
if (resumeUnit === 'hours') {
|
||||
waitAmount *= 60 * 60;
|
||||
}
|
||||
if (resumeUnit === 'days') {
|
||||
waitAmount *= 60 * 60 * 24;
|
||||
}
|
||||
|
||||
waitAmount *= 1000;
|
||||
waitTill = new Date(new Date().getTime() + waitAmount);
|
||||
} else {
|
||||
waitTill = new Date(context.getNodeParameter('maxDateAndTime', 0) as string);
|
||||
}
|
||||
}
|
||||
|
||||
return await this.putToWait(context, waitTill);
|
||||
}
|
||||
|
||||
private async putToWait(context: IExecuteFunctions, waitTill: Date) {
|
||||
await context.putExecutionToWait(waitTill);
|
||||
return [context.getInputData()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { DateTime } from 'luxon';
|
||||
import { NodeOperationError, type IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { Wait } from '../Wait.node';
|
||||
|
||||
describe('Execute Wait Node', () => {
|
||||
let timer: NodeJS.Timeout;
|
||||
const { clearInterval, setInterval } = global;
|
||||
const nextDay = DateTime.now().startOf('day').plus({ days: 1 });
|
||||
|
||||
beforeAll(() => {
|
||||
timer = setInterval(() => jest.advanceTimersByTime(1000), 10);
|
||||
jest.useFakeTimers().setSystemTime(new Date('2025-01-01'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
clearInterval(timer);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ value: 'invalid_date', isValid: false },
|
||||
{
|
||||
value: nextDay.toISO(),
|
||||
isValid: true,
|
||||
expectedWaitTill: nextDay.toJSDate(),
|
||||
},
|
||||
{
|
||||
value: nextDay.toISO({ includeOffset: true }),
|
||||
isValid: true,
|
||||
expectedWaitTill: nextDay.toUTC().toJSDate(),
|
||||
},
|
||||
{
|
||||
value: nextDay.toJSDate(),
|
||||
isValid: true,
|
||||
expectedWaitTill: nextDay.toJSDate(),
|
||||
},
|
||||
{
|
||||
value: nextDay,
|
||||
isValid: true,
|
||||
expectedWaitTill: nextDay.toJSDate(),
|
||||
},
|
||||
])(
|
||||
'Test Wait Node with specificTime $value and isValid $isValid',
|
||||
async ({ value, isValid, expectedWaitTill }) => {
|
||||
const putExecutionToWaitSpy = jest.fn();
|
||||
const waitNode = new Wait();
|
||||
const executeFunctionsMock = mock<IExecuteFunctions>({
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'resume') return 'specificTime';
|
||||
if (paramName === 'dateTime') return value;
|
||||
}),
|
||||
getTimezone: jest.fn().mockReturnValue('UTC'),
|
||||
putExecutionToWait: putExecutionToWaitSpy,
|
||||
getInputData: jest.fn(),
|
||||
getNode: jest.fn(),
|
||||
});
|
||||
|
||||
if (isValid) {
|
||||
await expect(waitNode.execute(executeFunctionsMock)).resolves.not.toThrow();
|
||||
expect(putExecutionToWaitSpy).toHaveBeenCalledWith(expectedWaitTill);
|
||||
} else {
|
||||
await expect(waitNode.execute(executeFunctionsMock)).rejects.toThrow(NodeOperationError);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('should resolve with input data if canceled', async () => {
|
||||
const putExecutionToWaitSpy = jest.fn();
|
||||
const waitNode = new Wait();
|
||||
|
||||
let cancelSignal: (() => void) | null = null;
|
||||
|
||||
const inputData = [{ json: { test: 'data' } }];
|
||||
|
||||
const executeFunctionsMock = mock<IExecuteFunctions>({
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'resume') return 'timeInterval';
|
||||
if (paramName === 'unit') return 'seconds';
|
||||
if (paramName === 'amount') return 60;
|
||||
}),
|
||||
getTimezone: jest.fn().mockReturnValue('UTC'),
|
||||
putExecutionToWait: putExecutionToWaitSpy,
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(),
|
||||
onExecutionCancellation: (handler) => {
|
||||
cancelSignal = handler;
|
||||
},
|
||||
});
|
||||
|
||||
const waitPromise = waitNode.execute(executeFunctionsMock);
|
||||
|
||||
for (let index = 0; index < 20; index++) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
if (cancelSignal !== null) break;
|
||||
}
|
||||
|
||||
expect(cancelSignal).not.toBeNull();
|
||||
cancelSignal!();
|
||||
|
||||
await expect(waitPromise).resolves.toEqual([inputData]);
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
describe('Time interval', () => {
|
||||
it.each([
|
||||
{
|
||||
unit: 'seconds',
|
||||
amount: 300,
|
||||
expectedWaitTill: () => DateTime.now().plus({ seconds: 300 }).toJSDate(),
|
||||
},
|
||||
{
|
||||
unit: 'minutes',
|
||||
amount: 2,
|
||||
expectedWaitTill: () => DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
},
|
||||
{
|
||||
unit: 'hours',
|
||||
amount: 1,
|
||||
expectedWaitTill: () => DateTime.now().plus({ hours: 1 }).toJSDate(),
|
||||
},
|
||||
{
|
||||
unit: 'days',
|
||||
amount: 10,
|
||||
expectedWaitTill: () => DateTime.now().plus({ days: 10 }).toJSDate(),
|
||||
},
|
||||
{
|
||||
unit: 'seconds',
|
||||
amount: 0,
|
||||
mode: 'timeout',
|
||||
expectedWaitTill: () => DateTime.now().toJSDate(),
|
||||
},
|
||||
{
|
||||
unit: 'seconds',
|
||||
amount: -10,
|
||||
error: 'Invalid wait amount. Please enter a number that is 0 or greater.',
|
||||
},
|
||||
{
|
||||
unit: 'years',
|
||||
amount: 10,
|
||||
error: "Invalid wait unit. Valid units are 'seconds', 'minutes', 'hours', or 'days'.",
|
||||
},
|
||||
{
|
||||
unit: 'minutes',
|
||||
amount: 'test',
|
||||
error: 'Invalid wait amount. Please enter a number that is 0 or greater.',
|
||||
},
|
||||
])(
|
||||
'Validate wait unit: $unit, amount: $amount',
|
||||
async ({ unit, amount, expectedWaitTill, error, mode }) => {
|
||||
const putExecutionToWaitSpy = jest.fn();
|
||||
const waitNode = new Wait();
|
||||
const inputData = [{ json: { inputData: true } }];
|
||||
const executeFunctionsMock = mock<IExecuteFunctions>({
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName: string) => {
|
||||
if (paramName === 'resume') return 'timeInterval';
|
||||
if (paramName === 'amount') return amount;
|
||||
if (paramName === 'unit') return unit;
|
||||
}),
|
||||
getTimezone: jest.fn().mockReturnValue('UTC'),
|
||||
putExecutionToWait: putExecutionToWaitSpy,
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(),
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
if (mode === 'timeout') {
|
||||
// for short wait times (<65s) a simple timeout is used
|
||||
const resultPromise = waitNode.execute(executeFunctionsMock);
|
||||
jest.runAllTimers();
|
||||
await expect(resultPromise).resolves.toEqual([inputData]);
|
||||
} else {
|
||||
// for longer wait times (>=65s) the execution is put to wait
|
||||
await expect(waitNode.execute(executeFunctionsMock)).resolves.not.toThrow();
|
||||
expect(putExecutionToWaitSpy).toHaveBeenCalledWith(expectedWaitTill?.());
|
||||
}
|
||||
} else {
|
||||
await expect(waitNode.execute(executeFunctionsMock)).rejects.toThrowError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests();
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"name": "[Unit Test] Wait Node",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "76e5dcfd-fdc7-472f-8832-bccc0eb122c0",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [120, 420]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"amount": 42,
|
||||
"unit": "seconds"
|
||||
},
|
||||
"id": "37f2c758-6fb2-43ce-86ae-ca11ec957cbd",
|
||||
"name": "Wait",
|
||||
"type": "n8n-nodes-base.wait",
|
||||
"typeVersion": 1,
|
||||
"position": [560, 420],
|
||||
"webhookId": "35edc971-c3e4-48cf-835d-4d73a4fd1fd8"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"number": [
|
||||
{
|
||||
"value1": "={{ parseInt($json.afterTimestamp) }}",
|
||||
"operation": "largerEqual",
|
||||
"value2": "={{ parseInt($json.startTimestamp) + 42 }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "c5c53934-2677-4adf-a4df-b32f3b0642a2",
|
||||
"name": "IF",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 1,
|
||||
"position": [960, 420]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"keepOnlySet": true,
|
||||
"values": {
|
||||
"boolean": [
|
||||
{
|
||||
"name": "success",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "a78417b6-d3f5-4bbc-916a-d4b9d46961cc",
|
||||
"name": "Set1",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 1,
|
||||
"position": [1180, 400]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"value": "={{ $now }}",
|
||||
"dataPropertyName": "afterTimestamp",
|
||||
"toFormat": "X",
|
||||
"options": {}
|
||||
},
|
||||
"id": "94f042ea-49d5-44ea-9ccf-93dac8d27d4a",
|
||||
"name": "After",
|
||||
"type": "n8n-nodes-base.dateTime",
|
||||
"typeVersion": 1,
|
||||
"position": [760, 420]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"value": "={{ $now }}",
|
||||
"dataPropertyName": "startTimestamp",
|
||||
"toFormat": "X",
|
||||
"options": {}
|
||||
},
|
||||
"id": "43f8a396-1bf7-484e-962c-120f677dfa51",
|
||||
"name": "Before",
|
||||
"type": "n8n-nodes-base.dateTime",
|
||||
"typeVersion": 1,
|
||||
"position": [360, 420]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Set1": [
|
||||
{
|
||||
"json": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Before",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Wait": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "After",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"IF": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Set1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"After": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "IF",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Before": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Wait",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {},
|
||||
"versionId": "8ed794a0-5c04-4b8a-9a49-02c2c7f8003f",
|
||||
"id": "500",
|
||||
"meta": {
|
||||
"instanceId": "8c8c5237b8e37b006a7adce87f4369350c58e41f3ca9de16196d3197f69eabcd"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function validateWaitAmount(amount: unknown): amount is number {
|
||||
return typeof amount === 'number' && amount >= 0;
|
||||
}
|
||||
|
||||
export type WaitUnit = 'seconds' | 'minutes' | 'hours' | 'days';
|
||||
export function validateWaitUnit(unit: unknown): unit is WaitUnit {
|
||||
return typeof unit === 'string' && ['seconds', 'minutes', 'hours', 'days'].includes(unit);
|
||||
}
|
||||
Reference in New Issue
Block a user