mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
fix(core): Only resume waiting parent workflows (#20342)
This commit is contained in:
@@ -145,64 +145,111 @@ describe('WaitTracker', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should also resume parent execution once sub-workflow finishes', async () => {
|
||||
const parentExecution = mock<IExecutionResponse>({
|
||||
id: 'parent_execution_id',
|
||||
finished: false,
|
||||
});
|
||||
parentExecution.workflowData = mock<IWorkflowBase>({ id: 'parent_workflow_id' });
|
||||
execution.data.parentExecution = {
|
||||
executionId: parentExecution.id,
|
||||
workflowId: parentExecution.workflowData.id,
|
||||
describe('parent execution restart behavior', () => {
|
||||
const setupParentExecutionTest = (shouldResume: boolean | undefined) => {
|
||||
const parentExecution = mock<IExecutionResponse>({
|
||||
id: 'parent_execution_id',
|
||||
finished: false,
|
||||
});
|
||||
parentExecution.workflowData = mock<IWorkflowBase>({ id: 'parent_workflow_id' });
|
||||
execution.data.parentExecution = {
|
||||
executionId: parentExecution.id,
|
||||
workflowId: parentExecution.workflowData.id,
|
||||
shouldResume,
|
||||
};
|
||||
executionRepository.findSingleExecution
|
||||
.calledWith(parentExecution.id)
|
||||
.mockResolvedValue(parentExecution);
|
||||
const postExecutePromise = createDeferredPromise<IRun | undefined>();
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith(execution.id)
|
||||
.mockReturnValue(postExecutePromise.promise);
|
||||
|
||||
return { parentExecution, postExecutePromise };
|
||||
};
|
||||
executionRepository.findSingleExecution
|
||||
.calledWith(parentExecution.id)
|
||||
.mockResolvedValue(parentExecution);
|
||||
const postExecutePromise = createDeferredPromise<IRun | undefined>();
|
||||
activeExecutions.getPostExecutePromise
|
||||
.calledWith(execution.id)
|
||||
.mockReturnValue(postExecutePromise.promise);
|
||||
|
||||
await waitTracker.startExecution(execution.id);
|
||||
it('should resume parent execution once sub-workflow finishes by default', async () => {
|
||||
const { parentExecution, postExecutePromise } = setupParentExecutionTest(undefined);
|
||||
|
||||
expect(executionRepository.findSingleExecution).toHaveBeenNthCalledWith(1, execution.id, {
|
||||
includeData: true,
|
||||
unflattenData: true,
|
||||
await waitTracker.startExecution(execution.id);
|
||||
|
||||
expect(executionRepository.findSingleExecution).toHaveBeenNthCalledWith(1, execution.id, {
|
||||
includeData: true,
|
||||
unflattenData: true,
|
||||
});
|
||||
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
|
||||
expect(workflowRunner.run).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
executionMode: execution.mode,
|
||||
executionData: execution.data,
|
||||
workflowData: execution.workflowData,
|
||||
projectId: project.id,
|
||||
pushRef: execution.data.pushRef,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
execution.id,
|
||||
);
|
||||
|
||||
postExecutePromise.resolve(mock<IRun>());
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(2);
|
||||
expect(workflowRunner.run).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
executionMode: parentExecution.mode,
|
||||
executionData: parentExecution.data,
|
||||
workflowData: parentExecution.workflowData,
|
||||
projectId: project.id,
|
||||
pushRef: parentExecution.data.pushRef,
|
||||
startedAt: parentExecution.startedAt,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
parentExecution.id,
|
||||
);
|
||||
});
|
||||
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
|
||||
expect(workflowRunner.run).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
executionMode: execution.mode,
|
||||
executionData: execution.data,
|
||||
workflowData: execution.workflowData,
|
||||
projectId: project.id,
|
||||
pushRef: execution.data.pushRef,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
execution.id,
|
||||
);
|
||||
it('should not resume parent execution when shouldResume is false', async () => {
|
||||
const { postExecutePromise } = setupParentExecutionTest(false);
|
||||
|
||||
postExecutePromise.resolve(mock<IRun>());
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
await waitTracker.startExecution(execution.id);
|
||||
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(2);
|
||||
expect(workflowRunner.run).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
executionMode: parentExecution.mode,
|
||||
executionData: parentExecution.data,
|
||||
workflowData: parentExecution.workflowData,
|
||||
projectId: project.id,
|
||||
pushRef: parentExecution.data.pushRef,
|
||||
startedAt: parentExecution.startedAt,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
parentExecution.id,
|
||||
);
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
|
||||
|
||||
postExecutePromise.resolve(mock<IRun>());
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Parent execution should NOT be started
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should resume parent execution when shouldResume is true', async () => {
|
||||
const { parentExecution, postExecutePromise } = setupParentExecutionTest(true);
|
||||
|
||||
await waitTracker.startExecution(execution.id);
|
||||
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
|
||||
|
||||
postExecutePromise.resolve(mock<IRun>());
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Parent execution SHOULD be started
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(2);
|
||||
expect(workflowRunner.run).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
executionMode: parentExecution.mode,
|
||||
projectId: project.id,
|
||||
}),
|
||||
false,
|
||||
false,
|
||||
parentExecution.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Project, Variables } from '@n8n/db';
|
||||
import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { getVariables } from '@/workflow-helpers';
|
||||
import { shouldRestartParentExecution } from '@/workflow-helpers';
|
||||
|
||||
describe('workflow-helpers', () => {
|
||||
beforeAll(() => {
|
||||
@@ -63,3 +64,36 @@ describe('workflow-helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRestartParentExecution', () => {
|
||||
it('should return false when parentExecution is undefined', () => {
|
||||
expect(shouldRestartParentExecution(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when shouldResume is explicitly false', () => {
|
||||
const parentExecution = {
|
||||
executionId: 'parent-exec-id',
|
||||
workflowId: 'parent-workflow-id',
|
||||
shouldResume: false,
|
||||
};
|
||||
expect(shouldRestartParentExecution(parentExecution)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when shouldResume is undefined', () => {
|
||||
const parentExecution = {
|
||||
executionId: 'parent-exec-id',
|
||||
workflowId: 'parent-workflow-id',
|
||||
shouldResume: undefined,
|
||||
};
|
||||
expect(shouldRestartParentExecution(parentExecution)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when shouldResume is true', () => {
|
||||
const parentExecution = {
|
||||
executionId: 'parent-exec-id',
|
||||
workflowId: 'parent-workflow-id',
|
||||
shouldResume: true,
|
||||
};
|
||||
expect(shouldRestartParentExecution(parentExecution)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { UnexpectedError, type IWorkflowExecutionDataProcess } from 'n8n-workflo
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
import { shouldRestartParentExecution } from './workflow-helpers';
|
||||
|
||||
@Service()
|
||||
export class WaitTracker {
|
||||
@@ -126,7 +127,7 @@ export class WaitTracker {
|
||||
await this.workflowRunner.run(data, false, false, executionId);
|
||||
|
||||
const { parentExecution } = fullExecutionData.data;
|
||||
if (parentExecution) {
|
||||
if (shouldRestartParentExecution(parentExecution)) {
|
||||
// on child execution completion, resume parent execution
|
||||
void this.activeExecutions.getPostExecutePromise(executionId).then(() => {
|
||||
void this.startExecution(parentExecution.executionId);
|
||||
|
||||
@@ -650,7 +650,7 @@ export async function executeWebhook(
|
||||
const executePromise = activeExecutions.getPostExecutePromise(executionId);
|
||||
|
||||
const { parentExecution } = runExecutionData;
|
||||
if (parentExecution) {
|
||||
if (WorkflowHelpers.shouldRestartParentExecution(parentExecution)) {
|
||||
// on child execution completion, resume parent execution
|
||||
void executePromise.then(() => {
|
||||
const waitTracker = Container.get(WaitTracker);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
IRun,
|
||||
ITaskData,
|
||||
IWorkflowBase,
|
||||
RelatedExecution,
|
||||
} from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -196,3 +197,21 @@ export async function getVariables(workflowId?: string, projectId?: string): Pro
|
||||
}, {} as IDataObject),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a parent execution should be restarted when a child execution completes.
|
||||
*
|
||||
* @param parentExecution - The parent execution metadata, if any
|
||||
* @returns true if the parent should be restarted, false otherwise
|
||||
*/
|
||||
export function shouldRestartParentExecution(
|
||||
parentExecution: RelatedExecution | undefined,
|
||||
): parentExecution is RelatedExecution {
|
||||
if (parentExecution === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (parentExecution.shouldResume === undefined) {
|
||||
return true; // Preserve existing behavior for executions started before the flag was introduced for backward compatibility.
|
||||
}
|
||||
return parentExecution.shouldResume;
|
||||
}
|
||||
|
||||
+63
-17
@@ -32,8 +32,9 @@ describe('ExecuteWorkflow', () => {
|
||||
executeFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('database') // source
|
||||
.mockReturnValueOnce('each') // mode
|
||||
.mockReturnValueOnce(true) // waitForSubWorkflow
|
||||
.mockReturnValueOnce([]); // workflowInputs.schema
|
||||
.mockReturnValueOnce({}) // workflowInputs.value
|
||||
.mockReturnValueOnce([]) // workflowInputs.schema
|
||||
.mockReturnValueOnce(true); // waitForSubWorkflow
|
||||
|
||||
executeFunctions.getInputData.mockReturnValue([{ json: { key: 'value' } }]);
|
||||
executeFunctions.getWorkflowDataProxy.mockReturnValue({
|
||||
@@ -51,8 +52,7 @@ describe('ExecuteWorkflow', () => {
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: { key: 'value' },
|
||||
index: 0,
|
||||
json: { key: 'subValue' },
|
||||
pairedItem: { item: 0 },
|
||||
metadata: {
|
||||
subExecution: { workflowId: 'subWorkflowId', executionId: 'subExecutionId' },
|
||||
@@ -60,16 +60,32 @@ describe('ExecuteWorkflow', () => {
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// Verify shouldResume is set correctly
|
||||
expect(executeFunctions.executeWorkflow).toHaveBeenCalledWith(
|
||||
{ id: 'subWorkflowId' },
|
||||
[{ json: { key: 'value' }, index: 0, pairedItem: { item: 0 }, binary: undefined }],
|
||||
undefined,
|
||||
{
|
||||
parentExecution: {
|
||||
executionId: 'executionId',
|
||||
workflowId: 'workflowId',
|
||||
shouldResume: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('should execute workflow in "once" mode and not wait for sub-workflow completion', async () => {
|
||||
executeFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('database') // source
|
||||
.mockReturnValueOnce('once') // mode
|
||||
.mockReturnValueOnce(false) // waitForSubWorkflow
|
||||
.mockReturnValueOnce([]); // workflowInputs.schema
|
||||
.mockReturnValueOnce({}) // workflowInputs.value
|
||||
.mockReturnValueOnce([]) // workflowInputs.schema
|
||||
.mockReturnValueOnce(false); // waitForSubWorkflow
|
||||
|
||||
executeFunctions.getInputData.mockReturnValue([{ json: { key: 'value' } }]);
|
||||
(getWorkflowInfo as jest.Mock).mockResolvedValue({ id: 'subWorkflowId' });
|
||||
|
||||
executeFunctions.executeWorkflow.mockResolvedValue({
|
||||
executionId: 'subExecutionId',
|
||||
@@ -78,15 +94,33 @@ describe('ExecuteWorkflow', () => {
|
||||
|
||||
const result = await executeWorkflow.execute.call(executeFunctions);
|
||||
|
||||
expect(result).toEqual([[{ json: { key: 'value' }, index: 0, pairedItem: { item: 0 } }]]);
|
||||
expect(result).toEqual([
|
||||
[{ json: { key: 'value' }, index: 0, pairedItem: { item: 0 }, binary: undefined }],
|
||||
]);
|
||||
|
||||
// Verify shouldResume is set to false
|
||||
expect(executeFunctions.executeWorkflow).toHaveBeenCalledWith(
|
||||
{ id: 'subWorkflowId' },
|
||||
[{ json: { key: 'value' }, index: 0, pairedItem: { item: 0 }, binary: undefined }],
|
||||
undefined,
|
||||
{
|
||||
doNotWaitToFinish: true,
|
||||
parentExecution: {
|
||||
executionId: 'executionId',
|
||||
workflowId: 'workflowId',
|
||||
shouldResume: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle errors and continue on fail, no items, < 1.3 version', async () => {
|
||||
executeFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('database') // source
|
||||
.mockReturnValueOnce('each') // mode
|
||||
.mockReturnValueOnce(true) // waitForSubWorkflow
|
||||
.mockReturnValueOnce([]); // workflowInputs.schema
|
||||
.mockReturnValueOnce({}) // workflowInputs.value
|
||||
.mockReturnValueOnce([]) // workflowInputs.schema
|
||||
.mockReturnValueOnce(true); // waitForSubWorkflow
|
||||
|
||||
executeFunctions.getNode.mockReturnValue({ typeVersion: 1.2 } as INode);
|
||||
|
||||
@@ -102,8 +136,13 @@ describe('ExecuteWorkflow', () => {
|
||||
executeFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('database') // source
|
||||
.mockReturnValueOnce('each') // mode
|
||||
.mockReturnValueOnce(true) // waitForSubWorkflow
|
||||
.mockReturnValue([]); // workflowInputs.schema
|
||||
.mockReturnValueOnce({}) // workflowInputs.value (item 0)
|
||||
.mockReturnValueOnce({}) // workflowInputs.value (item 1)
|
||||
.mockReturnValueOnce({}) // workflowInputs.value (item 2)
|
||||
.mockReturnValueOnce([]) // workflowInputs.schema
|
||||
.mockReturnValueOnce(true) // waitForSubWorkflow (item 0)
|
||||
.mockReturnValueOnce(true) // waitForSubWorkflow (item 1)
|
||||
.mockReturnValueOnce(true); // waitForSubWorkflow (item 2)
|
||||
|
||||
executeFunctions.getNode.mockReturnValue({ typeVersion: 1.2 } as INode);
|
||||
executeFunctions.getInputData.mockReturnValueOnce([
|
||||
@@ -128,8 +167,9 @@ describe('ExecuteWorkflow', () => {
|
||||
executeFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('database') // source
|
||||
.mockReturnValueOnce('each') // mode
|
||||
.mockReturnValueOnce(true) // waitForSubWorkflow
|
||||
.mockReturnValueOnce([]); // workflowInputs.schema
|
||||
.mockReturnValueOnce({}) // workflowInputs.value
|
||||
.mockReturnValueOnce([]) // workflowInputs.schema
|
||||
.mockReturnValueOnce(true); // waitForSubWorkflow
|
||||
|
||||
executeFunctions.getNode.mockReturnValue({ typeVersion: 1.3 } as INode);
|
||||
|
||||
@@ -145,8 +185,13 @@ describe('ExecuteWorkflow', () => {
|
||||
executeFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('database') // source
|
||||
.mockReturnValueOnce('each') // mode
|
||||
.mockReturnValueOnce(true) // waitForSubWorkflow
|
||||
.mockReturnValueOnce([]); // workflowInputs.schema
|
||||
.mockReturnValueOnce({}) // workflowInputs.value (item 0)
|
||||
.mockReturnValueOnce({}) // workflowInputs.value (item 1)
|
||||
.mockReturnValueOnce({}) // workflowInputs.value (item 2)
|
||||
.mockReturnValueOnce([]) // workflowInputs.schema
|
||||
.mockReturnValueOnce(true) // waitForSubWorkflow (item 0)
|
||||
.mockReturnValueOnce(true) // waitForSubWorkflow (item 1)
|
||||
.mockReturnValueOnce(true); // waitForSubWorkflow (item 2)
|
||||
|
||||
executeFunctions.getNode.mockReturnValue({ typeVersion: 1.3 } as INode);
|
||||
executeFunctions.getInputData.mockReturnValueOnce([
|
||||
@@ -173,8 +218,9 @@ describe('ExecuteWorkflow', () => {
|
||||
executeFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('database') // source
|
||||
.mockReturnValueOnce('each') // mode
|
||||
.mockReturnValueOnce(true) // waitForSubWorkflow
|
||||
.mockReturnValueOnce([]); // workflowInputs.schema
|
||||
.mockReturnValueOnce({}) // workflowInputs.value
|
||||
.mockReturnValueOnce([]) // workflowInputs.schema
|
||||
.mockReturnValueOnce(true); // waitForSubWorkflow
|
||||
|
||||
(getWorkflowInfo as jest.Mock).mockRejectedValue(new Error('Test error'));
|
||||
(executeFunctions.continueOnFail as jest.Mock).mockReturnValue(false);
|
||||
|
||||
@@ -317,6 +317,7 @@ export class ExecuteWorkflow implements INodeType {
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
shouldResume: waitForSubWorkflow,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -349,6 +350,7 @@ export class ExecuteWorkflow implements INodeType {
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
shouldResume: waitForSubWorkflow,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -416,6 +418,7 @@ export class ExecuteWorkflow implements INodeType {
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
shouldResume: waitForSubWorkflow,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -58,7 +58,7 @@ export class TestEntryComposer {
|
||||
* Returns the workflow import result for use in the test
|
||||
*/
|
||||
async fromImportedWorkflow(workflowFile: string) {
|
||||
const workflowImportResult = await this.n8n.api.workflows.importWorkflow(workflowFile);
|
||||
const workflowImportResult = await this.n8n.api.workflows.importWorkflowFromFile(workflowFile);
|
||||
await this.n8n.page.goto(`workflow/${workflowImportResult.workflowId}`);
|
||||
return workflowImportResult;
|
||||
}
|
||||
|
||||
@@ -155,14 +155,21 @@ export class WorkflowApiHelper {
|
||||
* The workflow will be created with its original active state from the JSON file.
|
||||
* Returns detailed information about what was imported, including webhook info if present.
|
||||
*/
|
||||
async importWorkflow(
|
||||
async importWorkflowFromFile(
|
||||
fileName: string,
|
||||
options?: { webhookPrefix?: string; idLength?: number; makeUnique?: boolean },
|
||||
): Promise<WorkflowImportResult> {
|
||||
const workflowDefinition: IWorkflowBase = JSON.parse(
|
||||
readFileSync(resolveFromRoot('workflows', fileName), 'utf8'),
|
||||
);
|
||||
const filePath = resolveFromRoot('workflows', fileName);
|
||||
const fileContent = readFileSync(filePath, 'utf8');
|
||||
const workflowDefinition = JSON.parse(fileContent) as IWorkflowBase;
|
||||
|
||||
return await this.importWorkflowFromDefinition(workflowDefinition, options);
|
||||
}
|
||||
|
||||
async importWorkflowFromDefinition(
|
||||
workflowDefinition: IWorkflowBase,
|
||||
options?: { webhookPrefix?: string; idLength?: number; makeUnique?: boolean },
|
||||
): Promise<WorkflowImportResult> {
|
||||
const result = await this.createWorkflowFromDefinition(workflowDefinition, options);
|
||||
|
||||
// Ensure the workflow is in the correct active state as specified in the JSON
|
||||
@@ -177,7 +184,6 @@ export class WorkflowApiHelper {
|
||||
const params = new URLSearchParams();
|
||||
if (workflowId) params.set('workflowId', workflowId);
|
||||
params.set('limit', limit.toString());
|
||||
|
||||
const response = await this.api.request.get('/rest/executions', { params });
|
||||
|
||||
if (!response.ok()) {
|
||||
|
||||
@@ -14,7 +14,7 @@ test.describe('Workflow Selector Parameter @db:reset', () => {
|
||||
];
|
||||
|
||||
for (const { file } of subWorkflows) {
|
||||
await n8n.api.workflows.importWorkflow(file);
|
||||
await n8n.api.workflows.importWorkflowFromFile(file);
|
||||
}
|
||||
|
||||
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
import { resolveFromRoot } from '../../utils/path-helper';
|
||||
import { retryUntil } from '../../utils/retry-utils';
|
||||
|
||||
test.describe('Parent that does not wait for sub-workflow', () => {
|
||||
test('should not wait for the sub-workflow', async ({ api }) => {
|
||||
const childWorkflowId = (
|
||||
await api.workflows.importWorkflowFromFile('subworkflow-wait-child.json')
|
||||
).workflowId;
|
||||
|
||||
const filePath = resolveFromRoot('workflows', 'subworkflow-parent-no-wait.json');
|
||||
const fileContent = readFileSync(filePath, 'utf8');
|
||||
const workflowDefinition = JSON.parse(fileContent) as IWorkflowBase;
|
||||
expect(workflowDefinition?.nodes[0]?.parameters).toBeDefined();
|
||||
// Replace the placeholder workflow ID with the actual child workflow ID
|
||||
workflowDefinition.nodes[0].parameters.workflowId = {
|
||||
value: childWorkflowId,
|
||||
mode: 'list',
|
||||
};
|
||||
const { webhookPath, workflowId } =
|
||||
await api.workflows.importWorkflowFromDefinition(workflowDefinition);
|
||||
|
||||
const response = await api.request.get(`/webhook/${webhookPath}`);
|
||||
expect(response.ok()).toBe(true);
|
||||
const execution = await api.workflows.waitForExecution(workflowId, 5000);
|
||||
expect(execution.status).toBe('success');
|
||||
|
||||
// The child workflow should still be running or waiting, since it's configured to wait 120s.
|
||||
const getExecutionsResponse = await api.workflows.getExecutions(childWorkflowId);
|
||||
// TODO: figure out why the filtering in `getExecutions` isn't working.
|
||||
const childExecutions = getExecutionsResponse.filter((e) => e.workflowId === childWorkflowId);
|
||||
expect(childExecutions.length).toBe(1);
|
||||
expect(childExecutions[0].status).toMatch(/running|waiting/);
|
||||
});
|
||||
|
||||
test('CAT-1445 should not be restarted by the child workflow finishing', async ({ api }) => {
|
||||
// The child is a no-op that returns immediately.
|
||||
const childWorkflowId = (
|
||||
await api.workflows.importWorkflowFromFile('subworkflow-noop-child.json')
|
||||
).workflowId;
|
||||
|
||||
// This is a parent that does NOT wait for the child to finish, but it has its own separate Wait node.
|
||||
// We want to verify that the parent is NOT restarted when the child finishes. This was fixed in CAT-1445.
|
||||
const filePath = resolveFromRoot('workflows', 'subworkflow-waiting-parent-no-child-wait.json');
|
||||
const fileContent = readFileSync(filePath, 'utf8');
|
||||
const workflowDefinition = JSON.parse(fileContent) as IWorkflowBase;
|
||||
expect(workflowDefinition?.nodes[1]?.parameters).toBeDefined();
|
||||
// Replace the placeholder workflow ID with the actual child workflow ID
|
||||
workflowDefinition.nodes[1].parameters.workflowId = {
|
||||
value: childWorkflowId,
|
||||
mode: 'list',
|
||||
};
|
||||
const { webhookPath, workflowId } =
|
||||
await api.workflows.importWorkflowFromDefinition(workflowDefinition);
|
||||
|
||||
const response = await api.request.get(`/webhook/${webhookPath}`);
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
// First, wait for the child to finish.
|
||||
const childExecution = await api.workflows.waitForExecution(childWorkflowId, 5000);
|
||||
expect(childExecution.status).toBe('success');
|
||||
|
||||
// Verify that the parent didn't get resumed. We might need to give it a moment to reach the waiting state.
|
||||
await retryUntil(
|
||||
async () => {
|
||||
const getExecutionsResponse = await api.workflows.getExecutions(workflowId);
|
||||
// TODO: figure out why the filtering in `getExecutions` isn't working.
|
||||
const parentExecutions = getExecutionsResponse.filter((e) => e.workflowId === workflowId);
|
||||
expect(parentExecutions.length).toBe(1);
|
||||
expect(parentExecutions[0].status).toBe('waiting');
|
||||
},
|
||||
{ timeoutMs: 2000, intervalMs: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ test.describe('External Webhook Triggering', () => {
|
||||
test('should create workflow via API, activate it, trigger webhook externally, and verify execution', async ({
|
||||
api,
|
||||
}) => {
|
||||
const { webhookPath, workflowId } = await api.workflows.importWorkflow(
|
||||
const { webhookPath, workflowId } = await api.workflows.importWorkflowFromFile(
|
||||
'simple-webhook-test.json',
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ test.describe('External Webhook Triggering', () => {
|
||||
});
|
||||
|
||||
test('should surface workflow configuration errors to the caller', async ({ api }) => {
|
||||
const { webhookPath } = await api.workflows.importWorkflow(
|
||||
const { webhookPath } = await api.workflows.importWorkflowFromFile(
|
||||
'webhook-misconfiguration-test.json',
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import { test, expect } from '../../fixtures/base';
|
||||
|
||||
test.describe('Webhook Origin Isolation', () => {
|
||||
test.beforeAll(async ({ api }) => {
|
||||
await api.workflows.importWorkflow('webhook-origin-isolation.json', { makeUnique: false });
|
||||
await api.workflows.importWorkflowFromFile('webhook-origin-isolation.json', {
|
||||
makeUnique: false,
|
||||
});
|
||||
});
|
||||
|
||||
const webhookPaths = [
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Retries the given assertion until it passes or the timeout is reached
|
||||
*
|
||||
* @example
|
||||
* await retryUntil(
|
||||
* () => expect(service.someState).toBe(true)
|
||||
* );
|
||||
*/
|
||||
export const retryUntil = async (
|
||||
assertion: () => Promise<void> | void,
|
||||
{ intervalMs = 200, timeoutMs = 5000 } = {},
|
||||
) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const tryAgain = () => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
resolve(await assertion());
|
||||
} catch (error) {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
} else {
|
||||
tryAgain();
|
||||
}
|
||||
}
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
tryAgain();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"inputSource": "passthrough"
|
||||
},
|
||||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [0, 0],
|
||||
"id": "e3fa278e-1cb1-48fb-88e4-301c7d04d22c",
|
||||
"name": "When Executed by Another Workflow"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When Executed by Another Workflow": {
|
||||
"main": [[]]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"workflowId": {
|
||||
"value": "PLACEHOLDER_SUBWORKFLOW_ID_TO_BE_REPLACED",
|
||||
"mode": "list"
|
||||
},
|
||||
"workflowInputs": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {}
|
||||
},
|
||||
"options": {
|
||||
"waitForSubWorkflow": false
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.3,
|
||||
"position": [208, 0],
|
||||
"id": "c27d9002-4bbe-4c13-b1a0-728b77a6ff98",
|
||||
"name": "Call Sub-workflow"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"path": "1eef9e1a-1639-48cb-a6f0-69ce704ddb35",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2.1,
|
||||
"position": [0, 0],
|
||||
"id": "ab4ea759-d26b-4b1b-8c66-89252df1a56a",
|
||||
"name": "Webhook",
|
||||
"webhookId": "1eef9e1a-1639-48cb-a6f0-69ce704ddb35"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Call Sub-workflow",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Call Sub-workflow": {
|
||||
"main": [[]]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"inputSource": "passthrough"
|
||||
},
|
||||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [0, 0],
|
||||
"id": "e3fa278e-1cb1-48fb-88e4-301c7d04d22c",
|
||||
"name": "When Executed by Another Workflow"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"amount": 120
|
||||
},
|
||||
"type": "n8n-nodes-base.wait",
|
||||
"typeVersion": 1.1,
|
||||
"position": [224, 0],
|
||||
"id": "1d7ee221-49ac-4744-970e-3ba78220b455",
|
||||
"name": "Wait"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When Executed by Another Workflow": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Wait",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Wait": {
|
||||
"main": [[]]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"meta": {
|
||||
"instanceId": "c0eda2ee62dc0e7e8288317ffba83079dda1ea0d80d2eaa966e6d217327a763e"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"path": "1eef9e1a-1639-48cb-a6f0-69ce704ddb35",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2.1,
|
||||
"position": [0, 0],
|
||||
"id": "ab4ea759-d26b-4b1b-8c66-89252df1a56a",
|
||||
"name": "Webhook",
|
||||
"webhookId": "1eef9e1a-1639-48cb-a6f0-69ce704ddb35"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"workflowId": {
|
||||
"value": "YJLfzxxKx2YBUjzH",
|
||||
"mode": "list"
|
||||
},
|
||||
"workflowInputs": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {}
|
||||
},
|
||||
"options": {
|
||||
"waitForSubWorkflow": false
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.3,
|
||||
"position": [272, 0],
|
||||
"id": "c27d9002-4bbe-4c13-b1a0-728b77a6ff98",
|
||||
"name": "Call Sub-workflow"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"amount": 120
|
||||
},
|
||||
"type": "n8n-nodes-base.wait",
|
||||
"typeVersion": 1.1,
|
||||
"position": [480, 0],
|
||||
"id": "273af435-67ac-419d-9208-26a6c83440bd",
|
||||
"name": "Wait",
|
||||
"webhookId": "1de20343-d792-49e1-922f-22a5364424a2"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Call Sub-workflow",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Call Sub-workflow": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Wait",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"meta": {}
|
||||
}
|
||||
@@ -2446,6 +2446,8 @@ export interface ITaskSubRunMetadata {
|
||||
export interface RelatedExecution {
|
||||
executionId: string;
|
||||
workflowId: string;
|
||||
// In the case of a parent execution, whether the parent should be resumed when the sub execution finishes.
|
||||
shouldResume?: boolean;
|
||||
}
|
||||
|
||||
type SubNodeExecutionDataAction = {
|
||||
|
||||
Reference in New Issue
Block a user