Merge branch 'master' into ado-4519-bug-update-0-versions-behind

This commit is contained in:
Nikhil Kuriakose 2025-12-15 13:48:41 +01:00 committed by GitHub
commit 2f242799fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 620 additions and 287 deletions

View File

@ -60,6 +60,7 @@
"@types/jest": "^29.5.3",
"@types/node": "*",
"@types/supertest": "^6.0.3",
"@babel/preset-env": "^7.26.0",
"babel-plugin-transform-import-meta": "^2.3.2",
"bundlemon": "^3.1.0",
"cross-env": "^7.0.3",

View File

@ -40,7 +40,7 @@ describe('ToolSerpApi', () => {
const node = new ToolSerpApi();
const inputData: INodeExecutionData[] = [
{
json: { query: 'artificial intelligence news' },
json: { input: 'artificial intelligence news' },
},
];
@ -69,17 +69,17 @@ describe('ToolSerpApi', () => {
},
],
]);
expect(SerpAPI.prototype.invoke).toHaveBeenCalledWith(inputData[0]);
expect(SerpAPI.prototype.invoke).toHaveBeenCalledWith(inputData[0].json);
});
it('should handle multiple input items', async () => {
const node = new ToolSerpApi();
const inputData: INodeExecutionData[] = [
{
json: { query: 'machine learning' },
json: { input: 'machine learning' },
},
{
json: { query: 'deep learning' },
json: { input: 'deep learning' },
},
];
@ -125,7 +125,7 @@ describe('ToolSerpApi', () => {
const node = new ToolSerpApi();
const inputData: INodeExecutionData[] = [
{
json: { query: 'test query' },
json: { input: 'test query' },
},
];
@ -144,5 +144,25 @@ describe('ToolSerpApi', () => {
expect(mockExecute.getCredentials).toHaveBeenCalledWith('serpApi');
expect(mockExecute.getNodeParameter).toHaveBeenCalledWith('options', 0);
});
it('should fail gracefully if input is missing', async () => {
const node = new ToolSerpApi();
const inputData: INodeExecutionData[] = [
{
json: {},
},
];
const mockExecute = mock<IExecuteFunctions>({
getInputData: jest.fn(() => inputData),
getNode: jest.fn(() => mock<INode>({ name: 'test serpapi' })),
getCredentials: jest.fn().mockResolvedValue({ apiKey: 'test-api-key' }),
getNodeParameter: jest.fn().mockReturnValue({}),
});
await expect(node.execute.call(mockExecute)).rejects.toThrow(
'Missing search query input at itemIndex 0',
);
});
});
});

View File

@ -1,4 +1,6 @@
import { SerpAPI } from '@langchain/community/tools/serpapi';
import { logWrapper } from '@utils/logWrapper';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
import {
type IExecuteFunctions,
NodeConnectionTypes,
@ -7,10 +9,9 @@ import {
type ISupplyDataFunctions,
type SupplyData,
type INodeExecutionData,
NodeOperationError,
} from 'n8n-workflow';
import { logWrapper } from '@utils/logWrapper';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
async function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions, itemIndex: number) {
const credentials = await ctx.getCredentials('serpApi');
@ -133,8 +134,17 @@ export class ToolSerpApi implements INodeType {
const returnData: INodeExecutionData[] = [];
for (let itemIndex = 0; itemIndex < inputData.length; itemIndex++) {
const tool = await getTool(this, itemIndex);
const query = inputData[itemIndex];
const result = await tool.invoke(query);
const item = inputData[itemIndex].json;
if (typeof item.input !== 'string' || !item.input) {
throw new NodeOperationError(
this.getNode(),
`Missing search query input at itemIndex ${itemIndex}`,
);
}
const result = (await tool.invoke(item)) as string;
returnData.push({
json: {
response: result,

View File

@ -134,16 +134,6 @@ export class ChatHubController {
res: Response,
@Body payload: ChatHubSendMessageRequest,
) {
this.logger.debug(
`Chat send request received: ${jsonStringify({
...payload,
attachments: payload.attachments?.map((a) => ({
fileName: a.fileName,
data: `${a.data.length} characters (Base64)`,
})),
})}`,
);
try {
await this.chatService.sendHumanMessage(res, req.user, {
...payload,
@ -186,8 +176,6 @@ export class ChatHubController {
@Param('messageId') editId: ChatMessageId,
@Body payload: ChatHubEditMessageRequest,
) {
this.logger.debug(`Chat edit request received: ${jsonStringify(payload)}`);
try {
await this.chatService.editMessage(res, req.user, {
...payload,
@ -232,8 +220,6 @@ export class ChatHubController {
@Param('messageId') retryId: ChatMessageId,
@Body payload: ChatHubRegenerateMessageRequest,
) {
this.logger.debug(`Chat retry request received: ${jsonStringify(payload)}`);
try {
await this.chatService.regenerateAIMessage(res, req.user, {
...payload,
@ -277,8 +263,6 @@ export class ChatHubController {
@Param('sessionId') sessionId: ChatSessionId,
@Param('messageId') messageId: ChatMessageId,
) {
this.logger.debug(`Chat stop request received: ${jsonStringify({ sessionId, messageId })}`);
await this.chatService.stopGeneration(req.user, sessionId, messageId);
res.status(204).send();
}

View File

@ -107,14 +107,6 @@ describe('TestWebhookRegistrationsService', () => {
});
});
describe('deregisterAll()', () => {
test('should deregister all test webhook registrations', async () => {
await registrations.deregisterAll();
expect(cacheService.delete).toHaveBeenCalledWith(cacheKey);
});
});
describe('toKey()', () => {
test('should convert a test webhook registration to a key', () => {
const result = registrations.toKey(registration.webhook);

View File

@ -113,10 +113,6 @@ export class TestWebhookRegistrationsService {
return val;
}
async deregisterAll() {
await this.cacheService.delete(this.cacheKey);
}
toKey(webhook: Pick<IWebhookData, 'webhookId' | 'httpMethod' | 'path'>) {
const { webhookId, httpMethod, path: webhookPath } = webhook;

View File

@ -500,9 +500,10 @@ export class TestWebhooks implements IWebhookManager {
if (staticData) workflow.staticData = staticData;
await this.webhookService.deleteWebhook(workflow, webhook, 'internal', 'update');
}
await this.registrations.deregisterAll();
// Deregister only this webhook, not all webhooks from other running workflows
await this.registrations.deregister(webhook);
}
}
/**

View File

@ -462,4 +462,121 @@ describe('NodeExecutionContext', () => {
);
});
});
describe('nodeFeatures', () => {
it('should return empty object when features are not defined', () => {
node.typeVersion = 2.4;
nodeType.description.features = undefined;
const result = testContext['nodeFeatures'];
expect(result).toEqual({});
});
it('should return enabled features based on node version', () => {
node.typeVersion = 2.4;
nodeType.description.features = {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
useFeatureB: { '@version': [{ _cnd: { lte: 2.1 } }] },
useFeatureC: { '@version': [{ _cnd: { gte: 2.2 } }] },
};
const result = testContext['nodeFeatures'];
expect(result).toEqual({
useFeatureA: true,
useFeatureB: false,
useFeatureC: true,
});
});
it('should return correct features for version 2.1', () => {
node.typeVersion = 2.1;
nodeType.description.features = {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
useFeatureB: { '@version': [{ _cnd: { lte: 2.1 } }] },
useFeatureC: { '@version': [{ _cnd: { gte: 2.2 } }] },
};
const result = testContext['nodeFeatures'];
expect(result).toEqual({
useFeatureA: false,
useFeatureB: true,
useFeatureC: false,
});
});
it('should handle simple version number conditions', () => {
node.typeVersion = 2;
nodeType.description.features = {
useFeatureD: { '@version': [2] },
useFeatureE: { '@version': [{ _cnd: { lt: 2.3 } }] },
};
const result = testContext['nodeFeatures'];
expect(result).toEqual({
useFeatureD: true,
useFeatureE: true,
});
});
});
describe('isNodeFeatureEnabled', () => {
it('should return true when feature is enabled', () => {
node.typeVersion = 2.4;
nodeType.description.features = {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
};
const result = testContext.isNodeFeatureEnabled('useFeatureA');
expect(result).toBe(true);
});
it('should return false when feature is disabled', () => {
node.typeVersion = 2.3;
nodeType.description.features = {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
};
const result = testContext.isNodeFeatureEnabled('useFeatureA');
expect(result).toBe(false);
});
it('should return false when feature does not exist', () => {
node.typeVersion = 2.4;
nodeType.description.features = {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
};
const result = testContext.isNodeFeatureEnabled('nonExistentFeature');
expect(result).toBe(false);
});
it('should return false when features are not defined', () => {
node.typeVersion = 2.4;
nodeType.description.features = undefined;
const result = testContext.isNodeFeatureEnabled('useFeatureA');
expect(result).toBe(false);
});
it('should handle multiple features correctly', () => {
node.typeVersion = 2.4;
nodeType.description.features = {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
useFeatureB: { '@version': [{ _cnd: { lte: 2.1 } }] },
useFeatureC: { '@version': [{ _cnd: { gte: 2.2 } }] },
};
expect(testContext.isNodeFeatureEnabled('useFeatureA')).toBe(true);
expect(testContext.isNodeFeatureEnabled('useFeatureB')).toBe(false);
expect(testContext.isNodeFeatureEnabled('useFeatureC')).toBe(true);
});
});
});

View File

@ -17,6 +17,7 @@ import type {
IRunExecutionData,
IWorkflowExecuteAdditionalData,
NodeConnectionType,
NodeFeatures,
NodeInputConnections,
NodeParameterValueType,
NodeTypeAndVersion,
@ -168,6 +169,26 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
return this.workflow.nodeTypes.getByNameAndVersion(type, typeVersion);
}
/**
* Gets the feature flags for the current node version.
* Uses declarative features from the node type description.
* @private
*/
@Memoized
private get nodeFeatures(): NodeFeatures {
const description = this.nodeType.description;
return NodeHelpers.getNodeFeatures(description.features, this.node.typeVersion);
}
/**
* Checks if a feature is enabled for the current node version.
* @param featureName - The name of the feature to check
* @returns true if the feature is enabled, false otherwise
*/
isNodeFeatureEnabled(featureName: string): boolean {
return this.nodeFeatures[featureName] ?? false;
}
@Memoized
get nodeInputs() {
return NodeHelpers.getNodeInputs(this.workflow, this.node, this.nodeType.description).map(

View File

@ -3068,7 +3068,6 @@
"workflowActivator.thisWorkflowIsArchived": "This workflow is archived so it cannot be activated",
"workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode": "'Execute Workflow Trigger' doesn't require activation as it is triggered by another workflow",
"workflowDetails.share": "Share",
"workflowDetails.active": "Active",
"workflowDetails.addTag": "Add tag",
"workflowDetails.chooseOrCreateATag": "Choose or create a tag",
"workflowProductionChecklist.title": "Production Checklist",
@ -3099,8 +3098,6 @@
"workflowExtraction.modal.title": "Convert to sub-workflow",
"workflowExtraction.modal.description": "Convert {nodeCount} node to a new sub-workflow | Convert {nodeCount} nodes to a new sub-workflow",
"workflowHelpers.showMessage.title": "Problem saving workflow",
"workflowOpen.active": "Active",
"workflowOpen.couldNotLoadActiveWorkflows": "Could not load active workflows",
"workflowOpen.created": "Created",
"workflowOpen.filterWorkflows": "Filter by tags",
"workflowOpen.name": "@:_reusableBaseText.name",
@ -3313,8 +3310,8 @@
"workflows.filters.tags": "Tags",
"workflows.filters.status": "Status",
"workflows.filters.status.all": "All",
"workflows.filters.status.active": "Active",
"workflows.filters.status.deactivated": "Deactivated",
"workflows.filters.status.active": "Published",
"workflows.filters.status.deactivated": "Unpublished",
"workflows.filters.showArchived": "Show archived workflows",
"workflows.filters.ownedBy": "Owned by",
"workflows.filters.sharedWith": "Shared with",

View File

@ -381,7 +381,7 @@ watch(
{ immediate: true },
);
function onSubmit(message: string, attachments: File[]) {
async function onSubmit(message: string, attachments: File[]) {
if (
!message.trim() ||
isResponding.value ||
@ -394,7 +394,7 @@ function onSubmit(message: string, attachments: File[]) {
didSubmitInCurrentSession.value = true;
editingMessageId.value = undefined;
void chatStore.sendMessage(
await chatStore.sendMessage(
sessionId.value,
message,
selectedModel.value.model,

View File

@ -125,9 +125,13 @@ const plugins: UserConfig['plugins'] = [
],
},
}),
legacy({
modernTargets: browsers,
}),
...(release
? [
legacy({
modernTargets: browsers,
}),
]
: []),
{
name: 'Insert config script',
transformIndexHtml: (html, ctx) => {

View File

@ -280,7 +280,7 @@ const getAllOperation: INodeProperties[] = [
},
options: [
{
displayName: 'Return Only Active Workflows',
displayName: 'Return Only Published Workflows',
name: 'activeWorkflows',
type: 'boolean',
default: true,

View File

@ -58,6 +58,7 @@ export class NodeParameterHelper {
break;
case 'text':
await this.ndv.setParameterInput(parameterName, value);
await this.ndv.waitForDebounce();
break;
case 'switch':
await this.ndv.setParameterSwitch(parameterName, value === 'true');
@ -80,10 +81,11 @@ export class NodeParameterHelper {
await this.setParameter('responseMode', config.responseMode, 'dropdown');
}
/**
* Simplified HTTP Request node parameter configuration
* @param config - Configuration object with parameter values
*/
async getWebhookPath(): Promise<string> {
const input = this.ndv.getParameterInputField('path');
return await input.inputValue();
}
async httpRequest(config: {
method?: string;
url?: string;

View File

@ -626,6 +626,12 @@ export class NodeDetailsViewPage extends BasePage {
await input.fill(value);
}
/** Waits for parameter input debounce (100ms) to flush. */
async waitForDebounce(): Promise<void> {
// eslint-disable-next-line playwright/no-wait-for-timeout
await this.page.waitForTimeout(150);
}
async clickGetBackToCanvas(): Promise<void> {
await this.clickBackToCanvasButton();
}

View File

@ -15,6 +15,7 @@ import { RoleApiHelper } from './role-api-helper';
import { TagApiHelper } from './tag-api-helper';
import { UserApiHelper } from './user-api-helper';
import { VariablesApiHelper } from './variables-api-helper';
import { WebhookApiHelper } from './webhook-api-helper';
import { WorkflowApiHelper } from './workflow-api-helper';
export interface LoginResponseData {
@ -39,6 +40,7 @@ const DB_TAGS = {
export class ApiHelpers {
request: APIRequestContext;
workflows: WorkflowApiHelper;
webhooks: WebhookApiHelper;
projects: ProjectApiHelper;
credentials: CredentialApiHelper;
variables: VariablesApiHelper;
@ -49,6 +51,7 @@ export class ApiHelpers {
constructor(requestContext: APIRequestContext) {
this.request = requestContext;
this.workflows = new WorkflowApiHelper(this);
this.webhooks = new WebhookApiHelper(this);
this.projects = new ProjectApiHelper(this);
this.credentials = new CredentialApiHelper(this);
this.variables = new VariablesApiHelper(this);

View File

@ -0,0 +1,44 @@
import type { APIResponse } from '@playwright/test';
import type { ApiHelpers } from './api-helper';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';
interface TriggerOptions {
method?: HttpMethod;
headers?: Record<string, string>;
data?: unknown;
/** Max retries for 404 (webhook not yet registered). Default: 3 */
maxNotFoundRetries?: number;
/** Delay between 404 retries in ms. Default: 250 */
notFoundRetryDelayMs?: number;
}
/** Triggers webhooks with retry for 404s (async registration) and connection errors. */
export class WebhookApiHelper {
constructor(private readonly api: ApiHelpers) {}
async trigger(path: string, options?: TriggerOptions): Promise<APIResponse> {
const maxNotFoundRetries = options?.maxNotFoundRetries ?? 3;
const notFoundRetryDelayMs = options?.notFoundRetryDelayMs ?? 250;
let lastResponse: APIResponse | undefined;
for (let attempt = 0; attempt <= maxNotFoundRetries; attempt++) {
lastResponse = await this.api.request.fetch(path, {
method: options?.method ?? 'GET',
headers: options?.headers,
data: options?.data,
maxRetries: 3, // Playwright retry for connection errors
});
if (lastResponse.status() !== 404 || attempt === maxNotFoundRetries) {
return lastResponse;
}
await new Promise((resolve) => setTimeout(resolve, notFoundRetryDelayMs));
}
return lastResponse!;
}
}

View File

@ -19,6 +19,7 @@ type WorkflowImportResult = {
createdWorkflow: IWorkflowBase;
webhookPath?: string;
webhookId?: string;
webhookMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';
};
export class WorkflowApiHelper {
@ -35,14 +36,7 @@ export class WorkflowApiHelper {
return result.data ?? result;
}
/**
* Creates a workflow in a project with optional folder placement (Uses Internal API not public API)
* @param project - Required project ID where the workflow will be created
* @param options - Optional configuration for workflow creation
* @param options.folder - Optional folder ID to place the workflow in
* @param options.name - Optional workflow name. If not provided, generates a unique name using nanoid
* @returns Object containing the name, ID, and versionId of the created workflow
*/
/** Creates a workflow in a project with optional folder placement. */
async createInProject(
project: string,
options?: {
@ -96,10 +90,7 @@ export class WorkflowApiHelper {
}
}
/**
* Make workflow unique by updating name, IDs, and webhook paths if present.
* This ensures no conflicts when importing workflows for testing.
*/
/** Makes workflow unique by updating name, IDs, and webhook paths. */
private makeWorkflowUnique(
workflow: Partial<IWorkflowBase>,
options?: { webhookPrefix?: string; idLength?: number },
@ -121,6 +112,7 @@ export class WorkflowApiHelper {
// Check if workflow has webhook nodes and process them
let webhookId: string | undefined;
let webhookPath: string | undefined;
let webhookMethod: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | undefined;
if (workflow.nodes) {
for (const node of workflow.nodes) {
@ -129,25 +121,24 @@ export class WorkflowApiHelper {
webhookPath = `${webhookPrefix}-${webhookId}`;
node.webhookId = webhookId;
node.parameters.path = webhookPath;
// Extract HTTP method from webhook node, default to GET
webhookMethod = (node.parameters.httpMethod as typeof webhookMethod) ?? 'GET';
}
}
}
return { webhookId, webhookPath, workflow };
return { webhookId, webhookPath, webhookMethod, workflow };
}
/**
* Create a workflow from an in-memory definition, making it unique for testing.
* Returns detailed information about what was created.
*/
/** Creates a workflow from definition, making it unique for testing. */
async createWorkflowFromDefinition(
workflow: Partial<IWorkflowBase>,
options?: { webhookPrefix?: string; idLength?: number; makeUnique?: boolean },
): Promise<WorkflowImportResult> {
const { makeUnique = true, ...rest } = options ?? {};
const { webhookPath, webhookId } = makeUnique
const { webhookPath, webhookId, webhookMethod } = makeUnique
? this.makeWorkflowUnique(workflow, rest)
: { webhookPath: undefined, webhookId: undefined };
: { webhookPath: undefined, webhookId: undefined, webhookMethod: undefined };
const createdWorkflow = await this.createWorkflow(workflow);
const workflowId: string = String(createdWorkflow.id);
@ -156,14 +147,11 @@ export class WorkflowApiHelper {
createdWorkflow,
webhookPath,
webhookId,
webhookMethod,
};
}
/**
* Import a workflow from file and make it unique for testing.
* 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.
*/
/** Imports a workflow from file, making it unique for testing. */
async importWorkflowFromFile(
fileName: string,
options?: { webhookPrefix?: string; idLength?: number; makeUnique?: boolean },
@ -179,7 +167,6 @@ export class WorkflowApiHelper {
workflowDefinition: Partial<IWorkflowBase>,
options?: { webhookPrefix?: string; idLength?: number; makeUnique?: boolean },
): Promise<WorkflowImportResult> {
// Store original active state
const result = await this.createWorkflowFromDefinition(workflowDefinition, options);
if (workflowDefinition.active) {
@ -188,7 +175,6 @@ export class WorkflowApiHelper {
return result;
}
// TODO: workflowId is being ignored
async getExecutions(workflowId?: string, limit = 20): Promise<ExecutionListResponse[]> {
const params = new URLSearchParams();
if (workflowId) params.set('workflowId', workflowId);
@ -229,7 +215,9 @@ export class WorkflowApiHelper {
if (executions.length > initialCount) {
for (const execution of executions.slice(0, executions.length - initialCount)) {
if (execution.status === 'success' || execution.status === 'error') {
const isCompleted = execution.status === 'success' || execution.status === 'error';
const isCorrectWorkflow = execution.workflowId === workflowId;
if (isCompleted && isCorrectWorkflow) {
return execution;
}
}
@ -237,7 +225,8 @@ export class WorkflowApiHelper {
for (const execution of executions) {
const isCompleted = execution.status === 'success' || execution.status === 'error';
if (isCompleted && execution.mode === 'webhook') {
const isCorrectWorkflow = execution.workflowId === workflowId;
if (isCompleted && isCorrectWorkflow && execution.mode === 'webhook') {
const executionTime = new Date(
execution.startedAt ?? execution.createdAt ?? Date.now(),
).getTime();
@ -253,14 +242,7 @@ export class WorkflowApiHelper {
throw new TestError(`Execution did not complete within ${timeoutMs}ms`);
}
/**
* Wait for a workflow execution to reach a specific status
* @param workflowId - The workflow ID to check
* @param expectedStatus - The expected status (e.g., 'waiting', 'success', 'error')
* @param timeoutMs - Maximum time to wait in milliseconds
* @returns The execution once it reaches the expected status
* @throws TestError if execution doesn't reach the expected status within timeout
*/
/** Waits for a workflow execution to reach a specific status. */
async waitForWorkflowStatus(
workflowId: string,
expectedStatus: string,

View File

@ -10,7 +10,8 @@ test.describe('External Webhook Triggering', () => {
const testPayload = { message: 'Hello from Playwright test' };
const webhookResponse = await api.request.post(`/webhook/${webhookPath}`, {
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`, {
method: 'POST',
data: testPayload,
});
@ -28,7 +29,7 @@ test.describe('External Webhook Triggering', () => {
'webhook-misconfiguration-test.json',
);
const webhookResponse = await api.request.get(`/webhook/${webhookPath}`);
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(webhookResponse.ok()).toBe(false);
expect(await webhookResponse.text()).toContain('Unused Respond to Webhook node');

View File

@ -31,7 +31,7 @@ test.describe('Webhook Origin Isolation', () => {
test(`Webhook responses should include the correct response headers for ${webhookPath}`, async ({
api,
}) => {
const webhookResponse = await api.request.get(`/webhook/${webhookPath}`);
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(webhookResponse.ok()).toBe(true);
const headers = webhookResponse.headers();

View File

@ -7,46 +7,47 @@ import { EditFieldsNode } from '../../../pages/nodes/EditFieldsNode';
const cowBase64 =
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=';
// eslint-disable-next-line n8n-local-rules/no-skipped-tests -- Flaky in multi-main mode due to webhook registration timing issues
test.describe.skip('Webhook Trigger node', () => {
test.describe.configure({ mode: 'serial' });
test.describe('Webhook Trigger node', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
const HTTP_METHODS = ['GET', 'POST', 'DELETE', 'HEAD', 'PATCH', 'PUT'];
for (const httpMethod of HTTP_METHODS) {
test(`should listen for a ${httpMethod} request`, async ({ n8n }) => {
const webhookPath = nanoid();
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({ httpMethod, path: webhookPath });
test('should listen for all HTTP methods (GET, POST, DELETE, HEAD, PATCH, PUT)', async ({
n8n,
}) => {
await n8n.canvas.addNode('Webhook');
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
const methods = ['GET', 'POST', 'DELETE', 'HEAD', 'PATCH', 'PUT'] as const;
for (const method of methods) {
await n8n.ndv.setupHelper.webhook({ httpMethod: method });
await n8n.ndv.execute();
await expect(n8n.ndv.getWebhookTestEvent()).toBeVisible();
const response = await n8n.api.request.fetch(`/webhook-test/${webhookPath}`, {
method: httpMethod,
});
expect(response.ok()).toBe(true);
});
}
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`, { method });
expect(response.ok(), `${method} request should succeed`).toBe(true);
// Wait for output to appear (confirms execution completed)
await expect(n8n.ndv.outputPanel.getDataContainer()).toBeVisible();
}
});
test('should listen for a GET request and respond with Respond to Webhook node', async ({
n8n,
}) => {
const webhookPath = nanoid();
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
path: webhookPath,
responseMode: "Using 'Respond to Webhook' Node",
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.close();
await addEditFieldsNode(n8n);
await n8n.canvas.addNode('Respond to Webhook', { closeNDV: true });
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.waitingForTriggerEvent()).toBeVisible();
const response = await n8n.api.request.get(`/webhook-test/${webhookPath}`);
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`);
expect(response.ok()).toBe(true);
const responseData = await response.json();
@ -56,28 +57,27 @@ test.describe.skip('Webhook Trigger node', () => {
test('should listen for a GET request and respond with custom status code 201', async ({
n8n,
}) => {
const webhookPath = nanoid();
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({ httpMethod: 'GET', path: webhookPath });
await n8n.ndv.setupHelper.webhook({ httpMethod: 'GET' });
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.setOptionalParameter('Response Code', 'responseCode', '201');
// Extra wait to ensure parameter is persisted before webhook registration
await n8n.ndv.waitForDebounce();
await n8n.ndv.execute();
await expect(n8n.ndv.getWebhookTestEvent()).toBeVisible();
const response = await n8n.api.request.get(`/webhook-test/${webhookPath}`);
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`);
expect(response.status()).toBe(201);
});
test('should listen for a GET request and respond with last node', async ({ n8n }) => {
const webhookPath = nanoid();
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
path: webhookPath,
responseMode: 'When Last Node Finishes',
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.close();
await addEditFieldsNode(n8n);
@ -86,7 +86,7 @@ test.describe.skip('Webhook Trigger node', () => {
await expect(n8n.canvas.waitingForTriggerEvent()).toBeVisible();
const response = await n8n.api.request.get(`/webhook-test/${webhookPath}`);
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`);
expect(response.ok()).toBe(true);
const responseData = await response.json();
@ -96,14 +96,12 @@ test.describe.skip('Webhook Trigger node', () => {
test('should listen for a GET request and respond with last node binary data', async ({
n8n,
}) => {
const webhookPath = nanoid();
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
path: webhookPath,
responseMode: 'When Last Node Finishes',
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.selectOptionInParameterDropdown('responseData', 'First Entry Binary');
await n8n.ndv.close();
@ -120,7 +118,7 @@ test.describe.skip('Webhook Trigger node', () => {
await expect(n8n.canvas.waitingForTriggerEvent()).toBeVisible();
const response = await n8n.api.request.get(`/webhook-test/${webhookPath}`);
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`);
expect(response.ok()).toBe(true);
const responseData = await response.json();
@ -128,19 +126,17 @@ test.describe.skip('Webhook Trigger node', () => {
});
test('should listen for a GET request and respond with an empty body', async ({ n8n }) => {
const webhookPath = nanoid();
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
path: webhookPath,
responseMode: 'When Last Node Finishes',
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.selectOptionInParameterDropdown('responseData', 'No Response Body');
await n8n.ndv.execute();
await expect(n8n.ndv.getWebhookTestEvent()).toBeVisible();
const response = await n8n.api.request.get(`/webhook-test/${webhookPath}`);
const response = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`);
expect(response.ok()).toBe(true);
const responseData = await response.text();
@ -148,7 +144,6 @@ test.describe.skip('Webhook Trigger node', () => {
});
test('should listen for a GET request with Basic Authentication', async ({ n8n }) => {
const webhookPath = nanoid();
const credentialName = `test-${nanoid()}`;
const user = `test-${nanoid()}`;
const password = `test-${nanoid()}`;
@ -164,21 +159,21 @@ test.describe.skip('Webhook Trigger node', () => {
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
path: webhookPath,
authentication: 'Basic Auth',
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.execute();
await expect(n8n.ndv.getWebhookTestEvent()).toBeVisible();
const failResponse = await n8n.api.request.get(`/webhook-test/${webhookPath}`, {
const failResponse = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`, {
headers: {
Authorization: 'Basic ' + Buffer.from('wrong:wrong').toString('base64'),
},
});
expect(failResponse.status()).toBe(403);
const successResponse = await n8n.api.request.get(`/webhook-test/${webhookPath}`, {
const successResponse = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`, {
headers: {
Authorization: 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64'),
},
@ -186,8 +181,7 @@ test.describe.skip('Webhook Trigger node', () => {
expect(successResponse.ok()).toBe(true);
});
test('should listen for a GET request with Header Authentication', async ({ n8n, api }) => {
const webhookPath = nanoid();
test('should listen for a GET request with Header Authentication', async ({ n8n }) => {
const credentialName = `test-${nanoid()}`;
const name = `test-${nanoid()}`;
const value = `test-${nanoid()}`;
@ -203,14 +197,14 @@ test.describe.skip('Webhook Trigger node', () => {
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
path: webhookPath,
authentication: 'Header Auth',
});
const webhookPath = await n8n.ndv.setupHelper.getWebhookPath();
await n8n.ndv.execute();
await expect(n8n.ndv.getWebhookTestEvent()).toBeVisible();
const failResponse = await api.request.get(`/webhook-test/${webhookPath}`, {
const failResponse = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`, {
headers: {
test: 'wrong',
},
@ -218,7 +212,7 @@ test.describe.skip('Webhook Trigger node', () => {
expect(failResponse.status()).toBe(403);
const successResponse = await api.request.get(`/webhook-test/${webhookPath}`, {
const successResponse = await n8n.api.webhooks.trigger(`/webhook-test/${webhookPath}`, {
headers: {
[name]: value,
},
@ -227,13 +221,10 @@ test.describe.skip('Webhook Trigger node', () => {
});
test('CAT-1253-bug-cant-run-workflow-when-unconnected-nodes-have-errors', async ({ n8n }) => {
const webhookPath = nanoid();
// Add Webhook node
await n8n.canvas.addNode('Webhook');
await n8n.ndv.setupHelper.webhook({
httpMethod: 'GET',
path: webhookPath,
});
await n8n.ndv.close();

View File

@ -123,8 +123,7 @@ test.describe('Execution', () => {
await expect(n8n.canvas.clearExecutionDataButton()).toBeHidden();
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests -- Flaky in multi-main mode due to webhook registration timing issues
test.skip('should test webhook workflow', async ({ n8n }) => {
test('should test webhook workflow', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Webhook_wait_set.json');
await expect(n8n.canvas.getExecuteWorkflowButton()).toBeVisible();

View File

@ -231,10 +231,7 @@ test.describe('Logs', () => {
await expect(n8n.executions.logsPanel.getLogEntries().nth(2)).toContainText('E2E Chat Model');
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests -- Flaky in multi-main mode due to webhook registration timing issues
test.skip('should show logs for a workflow with a node that waits for webhook', async ({
n8n,
}) => {
test('should show logs for a workflow with a node that waits for webhook', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Workflow_wait_for_webhook.json');
await n8n.canvas.deselectAll();
await n8n.canvas.logsPanel.open();
@ -270,10 +267,7 @@ test.describe('Logs', () => {
await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText('Success');
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests -- Flaky in multi-main mode due to webhook registration timing issues
test.skip('should allow to cancel a workflow with a node that waits for webhook', async ({
n8n,
}) => {
test('should allow to cancel a workflow with a node that waits for webhook', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('Workflow_wait_for_webhook.json');
await n8n.canvas.deselectAll();
await n8n.canvas.logsPanel.open();

View File

@ -31,20 +31,6 @@ test.describe('NDV', () => {
await expect(n8n.ndv.inputPanel.get()).toContainText('No input connected');
});
test('should test webhook node', async ({ n8n }) => {
await n8n.canvas.addNode('Webhook', { closeNDV: false });
await n8n.ndv.execute();
const webhookUrl = await n8n.ndv.getWebhookUrl();
await expect(n8n.ndv.getWebhookTriggerListening()).toBeVisible();
const response = await n8n.ndv.makeWebhookRequest(webhookUrl as string);
expect(response.status()).toBe(200);
await expect(n8n.ndv.outputPanel.get()).toBeVisible();
await expect(n8n.ndv.outputPanel.getDataContainer()).toBeVisible();
});
test('should change input and go back to canvas', async ({ n8n }) => {
await n8n.start.fromImportedWorkflow('NDV-test-select-input.json');
await n8n.canvas.clickZoomToFitButton();

View File

@ -24,7 +24,7 @@ test.describe('Parent that does not wait for sub-workflow', () => {
const { webhookPath, workflowId } =
await api.workflows.importWorkflowFromDefinition(workflowDefinition);
const response = await api.request.get(`/webhook/${webhookPath}`);
const response = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(response.ok()).toBe(true);
const execution = await api.workflows.waitForExecution(workflowId, 5000);
expect(execution.status).toBe('success');
@ -57,7 +57,7 @@ test.describe('Parent that does not wait for sub-workflow', () => {
const { webhookPath, workflowId } =
await api.workflows.importWorkflowFromDefinition(workflowDefinition);
const response = await api.request.get(`/webhook/${webhookPath}`);
const response = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(response.ok()).toBe(true);
// First, wait for the child to finish.
@ -105,7 +105,7 @@ test.describe('CAT-1801: Parent receives correct data from child with wait node'
await api.workflows.activate(parentWorkflowId, versionId!);
// Trigger parent workflow via webhook
const webhookResponse = await api.request.get(`/webhook/${webhookPath}`);
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(webhookResponse.ok()).toBe(true);
// Wait for child execution to appear and enter waiting state
@ -120,7 +120,9 @@ test.describe('CAT-1801: Parent receives correct data from child with wait node'
);
// Trigger the wait webhook to resume child using child execution ID
const waitWebhookResponse = await api.request.get(`/webhook-waiting/${childExecution!.id}`);
const waitWebhookResponse = await api.webhooks.trigger(
`/webhook-waiting/${childExecution!.id}`,
);
expect(waitWebhookResponse.ok()).toBe(true);
// Wait for parent to complete
@ -169,7 +171,7 @@ test.describe('CAT-1929: Parent should not resume until child with multiple wait
await api.workflows.activate(parentWorkflowId, versionId!);
// Trigger parent workflow via webhook
const webhookResponse = await api.request.get(`/webhook/${webhookPath}`);
const webhookResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(webhookResponse.ok()).toBe(true);
// Wait for child execution to appear and enter waiting state (first wait node)
@ -179,7 +181,7 @@ test.describe('CAT-1929: Parent should not resume until child with multiple wait
await api.workflows.waitForWorkflowStatus(parentWorkflowId, 'waiting');
// Resume first wait node
const firstWaitResponse = await api.request.get(`/webhook-waiting/${childExecution.id}`);
const firstWaitResponse = await api.webhooks.trigger(`/webhook-waiting/${childExecution.id}`);
expect(firstWaitResponse.ok()).toBe(true);
// Wait for child to reach the second wait node
@ -193,7 +195,7 @@ test.describe('CAT-1929: Parent should not resume until child with multiple wait
expect(parentExecAfterFirstWait.status).toBe('waiting');
// Resume second wait node
const secondWaitResponse = await api.request.get(`/webhook-waiting/${childExecution.id}`);
const secondWaitResponse = await api.webhooks.trigger(`/webhook-waiting/${childExecution.id}`);
expect(secondWaitResponse.ok()).toBe(true);
// Now parent should complete

View File

@ -951,7 +951,7 @@ export interface FunctionsBase {
getMode?: () => WorkflowExecuteMode;
getActivationMode?: () => WorkflowActivateMode;
getChatTrigger: () => INode | null;
isNodeFeatureEnabled(featureName: string): boolean;
getExecutionContext: () => IExecutionContext | undefined;
/** @deprecated */
@ -1549,12 +1549,17 @@ export type DisplayCondition =
| { _cnd: { regex: string } }
| { _cnd: { exists: true } };
export type NodeFeatures = Record<string, boolean>;
export type FeatureCondition = { '@version': Array<number | DisplayCondition> };
export type NodeFeaturesDefinition = Record<string, FeatureCondition>;
export interface IDisplayOptions {
hide?: {
[key: string]: Array<NodeParameterValue | DisplayCondition> | undefined;
};
show?: {
'@version'?: Array<number | DisplayCondition>;
'@feature'?: Array<string | DisplayCondition>;
'@tool'?: boolean[];
[key: string]: Array<NodeParameterValue | DisplayCondition> | undefined;
};
@ -2241,6 +2246,7 @@ export interface INodeTypeDescription extends INodeTypeBaseDescription {
communityNodePackageVersion?: string;
waitingNodeTooltip?: string;
__loadOptionsMethods?: string[]; // only for validation during build
features?: NodeFeaturesDefinition;
}
export type TriggerPanelDefinition = {

View File

@ -35,6 +35,9 @@ import type {
DisplayCondition,
NodeConnectionType,
ICredentialDataDecryptedObject,
FeatureCondition,
NodeFeaturesDefinition,
NodeFeatures,
} from './interfaces';
import { validateFilterParameter } from './node-parameters/filter-parameter';
import type { IRunExecutionData } from './run-execution-data/run-execution-data';
@ -252,6 +255,37 @@ export function isSubNodeType(
: false;
}
/**
* Evaluates a feature condition against a node version.
* @param featureDef The feature condition definition
* @param nodeVersion The node version to evaluate against
* @returns true if the feature is enabled, false otherwise
*/
function evaluateFeature(featureDef: FeatureCondition, nodeVersion: number): boolean {
return checkConditions(featureDef['@version'], [nodeVersion]);
}
/**
* Evaluates all feature definitions for a node type and returns the computed features.
* @param featuresDef The feature definitions from the node type description
* @param nodeVersion The node version to evaluate against
* @returns A record of feature names to their enabled state
*/
export function getNodeFeatures(
featuresDef: NodeFeaturesDefinition | undefined,
nodeVersion: number,
): NodeFeatures {
if (!featuresDef) {
return {};
}
const features: NodeFeatures = {};
for (const [featureName, condition] of Object.entries(featuresDef)) {
features[featureName] = evaluateFeature(condition, nodeVersion);
}
return features;
}
const getPropertyValues = (
nodeValues: INodeParameters | ICredentialDataDecryptedObject,
propertyName: string,
@ -267,6 +301,15 @@ const getPropertyValues = (
value = node?.typeVersion || 0;
} else if (propertyName === '@tool') {
value = nodeTypeDescription?.name.endsWith('Tool') ?? false;
} else if (propertyName === '@feature') {
if (!nodeTypeDescription?.features || !node?.typeVersion) {
return [];
}
const features = getNodeFeatures(nodeTypeDescription.features, node.typeVersion);
return Object.entries(features)
.filter(([_, enabled]) => enabled)
.map(([name]) => name);
} else {
// Get the value from current level
value = get(nodeValues, propertyName);
@ -296,6 +339,12 @@ const checkConditions = (
) {
const [key, targetValue] = Object.entries(condition._cnd)[0];
// Special case: empty array handling
if (actualValues.length === 0) {
if (key === 'not') return true; // Value is not present, so 'not' is true
return false; // For all other keys, empty array means condition is not met
}
return actualValues.every((propertyValue) => {
if (key === 'eq') {
return isEqual(propertyValue, targetValue);
@ -381,7 +430,7 @@ export function displayParameter(
return true;
}
if (values.length === 0 || !checkConditions(show[propertyName]!, values)) {
if (!checkConditions(show[propertyName]!, values)) {
return false;
}
}

View File

@ -302,6 +302,7 @@ export const IDisplayOptionsSchema: z.ZodType<IDisplayOptions> = z.object({
show: z
.object({
'@version': z.array(z.union([z.number(), DisplayConditionSchema])).optional(),
'@feature': z.array(z.union([z.string(), DisplayConditionSchema])).optional(),
'@tool': z.array(z.boolean()).optional(),
})
.catchall(

View File

@ -4866,6 +4866,205 @@ describe('NodeHelpers', () => {
},
false,
],
[
'Should return true if @feature is enabled (simple string array)',
{
...defaultTestInput,
node: {
...testNode,
typeVersion: 2.4,
},
nodeTypeDescription: {
...testNodeType,
features: {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
},
},
parameter: {
...defaultTestInput.parameter,
displayOptions: {
show: {
'@feature': ['useFeatureA'],
},
},
},
},
true,
],
[
'Should return false if @feature is not enabled (simple string array)',
{
...defaultTestInput,
node: {
...testNode,
typeVersion: 2.3,
},
nodeTypeDescription: {
...testNodeType,
features: {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
},
},
parameter: {
...defaultTestInput.parameter,
displayOptions: {
show: {
'@feature': ['useFeatureA'],
},
},
},
},
false,
],
[
'Should return true if @feature condition eq is met',
{
...defaultTestInput,
node: {
...testNode,
typeVersion: 2.4,
},
nodeTypeDescription: {
...testNodeType,
features: {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
},
},
parameter: {
...defaultTestInput.parameter,
displayOptions: {
show: {
'@feature': [{ _cnd: { eq: 'useFeatureA' } }],
},
},
},
},
true,
],
[
'Should return false if @feature condition eq is not met',
{
...defaultTestInput,
node: {
...testNode,
typeVersion: 2.3,
},
nodeTypeDescription: {
...testNodeType,
features: {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
},
},
parameter: {
...defaultTestInput.parameter,
displayOptions: {
show: {
'@feature': [{ _cnd: { eq: 'useFeatureA' } }],
},
},
},
},
false,
],
[
'Should return true if @feature condition not is met (feature disabled)',
{
...defaultTestInput,
node: {
...testNode,
typeVersion: 2.3,
},
nodeTypeDescription: {
...testNodeType,
features: {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
},
},
parameter: {
...defaultTestInput.parameter,
displayOptions: {
show: {
'@feature': [{ _cnd: { not: 'useFeatureA' } }],
},
},
},
},
true,
],
[
'Should return false if @feature condition not is not met (feature enabled)',
{
...defaultTestInput,
node: {
...testNode,
typeVersion: 2.4,
},
nodeTypeDescription: {
...testNodeType,
features: {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
},
},
parameter: {
...defaultTestInput.parameter,
displayOptions: {
show: {
'@feature': [{ _cnd: { not: 'useFeatureA' } }],
},
},
},
},
false,
],
[
'Should return true if @feature with multiple conditions (OR logic)',
{
...defaultTestInput,
node: {
...testNode,
typeVersion: 2.4,
},
nodeTypeDescription: {
...testNodeType,
features: {
useFeatureA: { '@version': [{ _cnd: { gte: 2.4 } }] },
useFeatureB: { '@version': [{ _cnd: { lte: 2.1 } }] },
},
},
parameter: {
...defaultTestInput.parameter,
displayOptions: {
show: {
'@feature': [{ _cnd: { eq: 'useFeatureA' } }, { _cnd: { eq: 'useFeatureB' } }],
},
},
},
},
true,
],
[
'Should return false if @feature is checked but node has no features',
{
...defaultTestInput,
node: {
...testNode,
typeVersion: 2.4,
},
nodeTypeDescription: {
...testNodeType,
features: undefined,
},
parameter: {
...defaultTestInput.parameter,
displayOptions: {
show: {
'@feature': ['useFeatureA'],
},
},
},
},
false,
],
];
for (const [description, input, expected] of tests) {

View File

@ -279,6 +279,9 @@ importers:
.:
devDependencies:
'@babel/preset-env':
specifier: ^7.26.0
version: 7.28.5(@babel/core@7.28.4)
'@biomejs/biome':
specifier: ^1.9.0
version: 1.9.0
@ -555,7 +558,7 @@ importers:
version: 4.0.7
axios:
specifier: 1.12.0
version: 1.12.0(debug@4.4.3)
version: 1.12.0(debug@4.4.1)
dotenv:
specifier: 17.2.3
version: 17.2.3
@ -580,7 +583,7 @@ importers:
dependencies:
axios:
specifier: 1.12.0
version: 1.12.0(debug@4.4.3)
version: 1.12.0(debug@4.4.1)
devDependencies:
'@n8n/typescript-config':
specifier: workspace:*
@ -1340,7 +1343,7 @@ importers:
version: link:../eslint-plugin-community-nodes
axios:
specifier: 1.12.0
version: 1.12.0(debug@4.4.3)
version: 1.12.0(debug@4.4.1)
eslint:
specifier: 'catalog:'
version: 9.29.0(jiti@2.6.1)
@ -1554,7 +1557,7 @@ importers:
version: 1.11.0
axios:
specifier: 1.12.0
version: 1.12.0(debug@4.4.3)
version: 1.12.0(debug@4.4.1)
bcryptjs:
specifier: 2.4.3
version: 2.4.3
@ -1909,7 +1912,7 @@ importers:
version: 9.42.1
axios:
specifier: 1.12.0
version: 1.12.0(debug@4.4.3)
version: 1.12.0(debug@4.4.1)
callsites:
specifier: 'catalog:'
version: 3.1.0
@ -2376,7 +2379,7 @@ importers:
version: link:../../../@n8n/utils
axios:
specifier: 1.12.0
version: 1.12.0(debug@4.4.3)
version: 1.12.0(debug@4.4.1)
flatted:
specifier: 'catalog:'
version: 3.2.7
@ -2633,7 +2636,7 @@ importers:
version: 1.1.4
axios:
specifier: 1.12.0
version: 1.12.0(debug@4.4.3)
version: 1.12.0(debug@4.4.1)
bowser:
specifier: 2.11.0
version: 2.11.0
@ -3963,10 +3966,6 @@ packages:
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.27.5':
resolution: {integrity: sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.28.5':
resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==}
engines: {node: '>=6.9.0'}
@ -3983,10 +3982,6 @@ packages:
resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-annotate-as-pure@7.25.9':
resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==}
engines: {node: '>=6.9.0'}
'@babel/helper-annotate-as-pure@7.27.3':
resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==}
engines: {node: '>=6.9.0'}
@ -4001,12 +3996,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-create-regexp-features-plugin@7.26.3':
resolution: {integrity: sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-create-regexp-features-plugin@7.28.5':
resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==}
engines: {node: '>=6.9.0'}
@ -4578,10 +4567,6 @@ packages:
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.28.4':
resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.28.5':
resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==}
engines: {node: '>=6.9.0'}
@ -15900,10 +15885,6 @@ packages:
reftools@1.1.9:
resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==}
regenerate-unicode-properties@10.2.0:
resolution: {integrity: sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==}
engines: {node: '>=4'}
regenerate-unicode-properties@10.2.2:
resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==}
engines: {node: '>=4'}
@ -15930,10 +15911,6 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
regexpu-core@6.2.0:
resolution: {integrity: sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==}
engines: {node: '>=4'}
regexpu-core@6.4.0:
resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==}
engines: {node: '>=4'}
@ -17545,10 +17522,6 @@ packages:
resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==}
engines: {node: '>=4'}
unicode-match-property-value-ecmascript@2.2.0:
resolution: {integrity: sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==}
engines: {node: '>=4'}
unicode-match-property-value-ecmascript@2.2.1:
resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==}
engines: {node: '>=4'}
@ -17941,8 +17914,8 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.1.7:
resolution: {integrity: sha512-SxZOijzRU0LiTYARVZNYf//21bZRLJ26P2prcA6a+FfDQxJtan4tEnsryIY7aiLh2LVoet0ci2G+6RrArQ+7tA==}
vue-component-type-helpers@3.1.8:
resolution: {integrity: sha512-oaowlmEM6BaYY+8o+9D9cuzxpWQWHqHTMKakMxXu0E+UCIOMTljyIPO15jcnaCwJtZu/zWDotK7mOIHvWD9mcw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@ -20273,8 +20246,6 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/compat-data@7.27.5': {}
'@babel/compat-data@7.28.5': {}
'@babel/core@7.28.4':
@ -20286,7 +20257,7 @@ snapshots:
'@babel/helpers': 7.28.4
'@babel/parser': 7.28.5
'@babel/template': 7.27.2
'@babel/traverse': 7.28.4
'@babel/traverse': 7.28.5
'@babel/types': 7.28.5
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
@ -20313,17 +20284,13 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/helper-annotate-as-pure@7.25.9':
dependencies:
'@babel/types': 7.28.5
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
'@babel/types': 7.28.5
'@babel/helper-compilation-targets@7.27.2':
dependencies:
'@babel/compat-data': 7.27.5
'@babel/compat-data': 7.28.5
'@babel/helper-validator-option': 7.27.1
browserslist: 4.27.0
lru-cache: 5.1.1
@ -20342,13 +20309,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/helper-create-regexp-features-plugin@7.26.3(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-annotate-as-pure': 7.25.9
regexpu-core: 6.2.0
semver: 7.7.3
'@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
@ -20403,7 +20363,7 @@ snapshots:
'@babel/core': 7.28.4
'@babel/helper-annotate-as-pure': 7.27.3
'@babel/helper-wrap-function': 7.28.3
'@babel/traverse': 7.28.4
'@babel/traverse': 7.28.5
transitivePeerDependencies:
- supports-color
@ -20412,13 +20372,13 @@ snapshots:
'@babel/core': 7.28.4
'@babel/helper-member-expression-to-functions': 7.28.5
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/traverse': 7.28.4
'@babel/traverse': 7.28.5
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
'@babel/traverse': 7.28.5
'@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@ -20434,7 +20394,7 @@ snapshots:
'@babel/helper-wrap-function@7.28.3':
dependencies:
'@babel/template': 7.27.2
'@babel/traverse': 7.28.4
'@babel/traverse': 7.28.5
'@babel/types': 7.28.5
transitivePeerDependencies:
- supports-color
@ -20483,7 +20443,7 @@ snapshots:
dependencies:
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/traverse': 7.28.4
'@babel/traverse': 7.28.5
transitivePeerDependencies:
- supports-color
@ -20594,7 +20554,7 @@ snapshots:
'@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-create-regexp-features-plugin': 7.26.3(@babel/core@7.28.4)
'@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.28.4)
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.28.4)':
@ -20607,7 +20567,7 @@ snapshots:
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.28.4)
'@babel/traverse': 7.28.4
'@babel/traverse': 7.28.5
transitivePeerDependencies:
- supports-color
@ -20654,7 +20614,7 @@ snapshots:
'@babel/helper-globals': 7.28.0
'@babel/helper-plugin-utils': 7.27.1
'@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4)
'@babel/traverse': 7.28.4
'@babel/traverse': 7.28.5
transitivePeerDependencies:
- supports-color
@ -20725,7 +20685,7 @@ snapshots:
'@babel/core': 7.28.4
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-plugin-utils': 7.27.1
'@babel/traverse': 7.28.4
'@babel/traverse': 7.28.5
transitivePeerDependencies:
- supports-color
@ -20811,7 +20771,7 @@ snapshots:
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.28.4)
'@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.28.4)
'@babel/traverse': 7.28.4
'@babel/traverse': 7.28.5
transitivePeerDependencies:
- supports-color
@ -21031,18 +20991,6 @@ snapshots:
'@babel/parser': 7.28.5
'@babel/types': 7.28.5
'@babel/traverse@7.28.4':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.28.5
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.5
'@babel/template': 7.27.2
'@babel/types': 7.28.5
debug: 4.4.3
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.28.5':
dependencies:
'@babel/code-frame': 7.27.1
@ -23628,7 +23576,7 @@ snapshots:
'@rudderstack/rudder-sdk-node@2.1.4(tslib@2.8.1)':
dependencies:
axios: 1.12.0(debug@4.4.3)
axios: 1.12.0(debug@4.4.1)
axios-retry: 4.5.0(axios@1.12.0)
component-type: 2.0.0
join-component: 1.1.0
@ -24930,7 +24878,7 @@ snapshots:
storybook: 9.1.7(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@5.0.10)(vite@7.0.0(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.89.2)(terser@5.16.1)(tsx@4.19.3))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.7
vue-component-type-helpers: 3.1.8
'@stylistic/eslint-plugin@5.0.0(eslint@9.29.0(jiti@2.6.1))':
dependencies:
@ -26719,7 +26667,7 @@ snapshots:
axios-retry@4.5.0(axios@1.12.0):
dependencies:
axios: 1.12.0(debug@4.4.3)
axios: 1.12.0(debug@4.4.1)
is-retry-allowed: 2.2.0
axios@1.12.0(debug@4.3.6):
@ -26738,14 +26686,6 @@ snapshots:
transitivePeerDependencies:
- debug
axios@1.12.0(debug@4.4.3):
dependencies:
follow-redirects: 1.15.11(debug@4.4.1)
form-data: 4.0.4
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
b4a@1.6.7: {}
babel-jest@29.6.2(@babel/core@7.28.4):
@ -27049,10 +26989,10 @@ snapshots:
browserslist@4.25.0:
dependencies:
caniuse-lite: 1.0.30001724
electron-to-chromium: 1.5.173
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.0)
caniuse-lite: 1.0.30001752
electron-to-chromium: 1.5.244
node-releases: 2.0.27
update-browserslist-db: 1.1.4(browserslist@4.25.0)
browserslist@4.27.0:
dependencies:
@ -27132,7 +27072,7 @@ snapshots:
bundlemon@3.1.0(typescript@5.9.2):
dependencies:
axios: 1.12.0(debug@4.4.3)
axios: 1.12.0(debug@4.4.1)
axios-retry: 4.5.0(axios@1.12.0)
brotli-size: 4.0.0
bundlemon-utils: 2.0.1
@ -30235,7 +30175,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 20.19.21
'@types/tough-cookie': 4.0.5
axios: 1.12.0(debug@4.4.3)
axios: 1.12.0(debug@4.4.1)
camelcase: 6.3.0
debug: 4.4.3
dotenv: 16.6.1
@ -30323,7 +30263,7 @@ snapshots:
infisical-node@1.3.0:
dependencies:
axios: 1.12.0(debug@4.4.3)
axios: 1.12.0(debug@4.4.1)
dotenv: 16.6.1
tweetnacl: 1.0.3
tweetnacl-util: 0.15.1
@ -33903,7 +33843,7 @@ snapshots:
posthog-node@3.2.1:
dependencies:
axios: 1.12.0(debug@4.4.3)
axios: 1.12.0(debug@4.4.1)
rusha: 0.8.14
transitivePeerDependencies:
- debug
@ -34470,10 +34410,6 @@ snapshots:
reftools@1.1.9: {}
regenerate-unicode-properties@10.2.0:
dependencies:
regenerate: 1.4.2
regenerate-unicode-properties@10.2.2:
dependencies:
regenerate: 1.4.2
@ -34509,15 +34445,6 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
regexpu-core@6.2.0:
dependencies:
regenerate: 1.4.2
regenerate-unicode-properties: 10.2.0
regjsgen: 0.8.0
regjsparser: 0.12.0
unicode-match-property-ecmascript: 2.0.0
unicode-match-property-value-ecmascript: 2.2.0
regexpu-core@6.4.0:
dependencies:
regenerate: 1.4.2
@ -34628,7 +34555,7 @@ snapshots:
retry-axios@2.6.0(axios@1.12.0(debug@4.4.3)):
dependencies:
axios: 1.12.0(debug@4.4.3)
axios: 1.12.0(debug@4.4.1)
retry-request@7.0.2(encoding@0.1.13):
dependencies:
@ -35234,7 +35161,7 @@ snapshots:
asn1.js: 5.4.1
asn1.js-rfc2560: 5.0.1(asn1.js@5.4.1)
asn1.js-rfc5280: 3.0.0
axios: 1.12.0(debug@4.4.3)
axios: 1.12.0(debug@4.4.1)
big-integer: 1.6.52
bignumber.js: 9.1.2
binascii: 0.0.2
@ -36565,8 +36492,6 @@ snapshots:
unicode-canonical-property-names-ecmascript: 2.0.1
unicode-property-aliases-ecmascript: 2.1.0
unicode-match-property-value-ecmascript@2.2.0: {}
unicode-match-property-value-ecmascript@2.2.1: {}
unicode-property-aliases-ecmascript@2.1.0: {}
@ -36652,7 +36577,7 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
update-browserslist-db@1.1.3(browserslist@4.25.0):
update-browserslist-db@1.1.4(browserslist@4.25.0):
dependencies:
browserslist: 4.25.0
escalade: 3.2.0
@ -36962,7 +36887,7 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.1.7: {}
vue-component-type-helpers@3.1.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies: