From 356e3e69bca44fea866a195fac167901744e8dd9 Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Tue, 27 Jan 2026 16:52:08 +0000 Subject: [PATCH] feat: Add Currents.dev node (#24566) --- .../credentials/CurrentsApi.credentials.ts | 43 ++ .../nodes/Currents/Currents.node.ts | 130 ++++ .../nodes/Currents/CurrentsTrigger.node.ts | 248 +++++++ .../nodes/Currents/CurrentsTriggerHelpers.ts | 191 ++++++ .../nodes-base/nodes/Currents/currents.svg | 1 + .../descriptions/ActionDescription.ts | 502 ++++++++++++++ .../descriptions/InstanceDescription.ts | 57 ++ .../descriptions/ProjectDescription.ts | 268 ++++++++ .../Currents/descriptions/RunDescription.ts | 613 ++++++++++++++++++ .../descriptions/SignatureDescription.ts | 128 ++++ .../descriptions/SpecFileDescription.ts | 307 +++++++++ .../Currents/descriptions/TestDescription.ts | 342 ++++++++++ .../descriptions/TestResultDescription.ts | 251 +++++++ .../descriptions/common.descriptions.ts | 28 + .../nodes/Currents/methods/index.ts | 5 + .../nodes/Currents/methods/listSearch.ts | 33 + .../Currents/test/Currents.structure.test.ts | 94 +++ .../Currents/test/CurrentsTrigger.test.ts | 147 +++++ .../test/CurrentsTriggerHelpers.test.ts | 502 ++++++++++++++ .../nodes/Currents/test/listSearch.test.ts | 116 ++++ packages/nodes-base/package.json | 3 + .../testing/containers/n8n-start-stack.ts | 14 + packages/testing/containers/services/ngrok.ts | 97 +++ .../testing/containers/services/registry.ts | 2 + packages/testing/containers/services/types.ts | 1 + .../testing/containers/test-containers.ts | 1 + 26 files changed, 4124 insertions(+) create mode 100644 packages/nodes-base/credentials/CurrentsApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Currents/Currents.node.ts create mode 100644 packages/nodes-base/nodes/Currents/CurrentsTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Currents/CurrentsTriggerHelpers.ts create mode 100644 packages/nodes-base/nodes/Currents/currents.svg create mode 100644 packages/nodes-base/nodes/Currents/descriptions/ActionDescription.ts create mode 100644 packages/nodes-base/nodes/Currents/descriptions/InstanceDescription.ts create mode 100644 packages/nodes-base/nodes/Currents/descriptions/ProjectDescription.ts create mode 100644 packages/nodes-base/nodes/Currents/descriptions/RunDescription.ts create mode 100644 packages/nodes-base/nodes/Currents/descriptions/SignatureDescription.ts create mode 100644 packages/nodes-base/nodes/Currents/descriptions/SpecFileDescription.ts create mode 100644 packages/nodes-base/nodes/Currents/descriptions/TestDescription.ts create mode 100644 packages/nodes-base/nodes/Currents/descriptions/TestResultDescription.ts create mode 100644 packages/nodes-base/nodes/Currents/descriptions/common.descriptions.ts create mode 100644 packages/nodes-base/nodes/Currents/methods/index.ts create mode 100644 packages/nodes-base/nodes/Currents/methods/listSearch.ts create mode 100644 packages/nodes-base/nodes/Currents/test/Currents.structure.test.ts create mode 100644 packages/nodes-base/nodes/Currents/test/CurrentsTrigger.test.ts create mode 100644 packages/nodes-base/nodes/Currents/test/CurrentsTriggerHelpers.test.ts create mode 100644 packages/nodes-base/nodes/Currents/test/listSearch.test.ts create mode 100644 packages/testing/containers/services/ngrok.ts diff --git a/packages/nodes-base/credentials/CurrentsApi.credentials.ts b/packages/nodes-base/credentials/CurrentsApi.credentials.ts new file mode 100644 index 00000000000..3aed820352f --- /dev/null +++ b/packages/nodes-base/credentials/CurrentsApi.credentials.ts @@ -0,0 +1,43 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class CurrentsApi implements ICredentialType { + name = 'currentsApi'; + + displayName = 'Currents API'; + + documentationUrl = 'https://docs.currents.dev/api'; + + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { password: true }, + default: '', + required: true, + description: 'API key from Currents Dashboard (Organization > API & Record Keys)', + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.apiKey}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: 'https://api.currents.dev/v1', + url: '/projects', + method: 'GET', + }, + }; +} diff --git a/packages/nodes-base/nodes/Currents/Currents.node.ts b/packages/nodes-base/nodes/Currents/Currents.node.ts new file mode 100644 index 00000000000..2442d1e6a2d --- /dev/null +++ b/packages/nodes-base/nodes/Currents/Currents.node.ts @@ -0,0 +1,130 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; + +import { actionFields, actionOperations } from './descriptions/ActionDescription'; +import { listSearch } from './methods'; +import { instanceFields, instanceOperations } from './descriptions/InstanceDescription'; +import { projectFields, projectOperations } from './descriptions/ProjectDescription'; +import { runFields, runOperations } from './descriptions/RunDescription'; +import { signatureFields, signatureOperations } from './descriptions/SignatureDescription'; +import { specFileFields, specFileOperations } from './descriptions/SpecFileDescription'; +import { testFields, testOperations } from './descriptions/TestDescription'; +import { testResultFields, testResultOperations } from './descriptions/TestResultDescription'; + +export class Currents implements INodeType { + description: INodeTypeDescription = { + displayName: 'Currents', + name: 'currents', + icon: 'file:currents.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Interact with the Currents API for test orchestration and analytics', + defaults: { + name: 'Currents', + }, + usableAsTool: true, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'currentsApi', + required: true, + }, + ], + requestDefaults: { + baseURL: 'https://api.currents.dev/v1', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Action', + value: 'action', + description: 'Test action rules (skip, quarantine, tag)', + }, + { + name: 'Instance', + value: 'instance', + description: 'Spec file execution instance', + }, + { + name: 'Project', + value: 'project', + description: 'Test project', + }, + { + name: 'Run', + value: 'run', + description: 'Test run', + }, + { + name: 'Signature', + value: 'signature', + description: 'Generate unique test signatures', + }, + { + name: 'Spec File', + value: 'specFile', + description: 'Spec file performance metrics', + }, + { + name: 'Test', + value: 'test', + description: 'Individual test performance metrics', + }, + { + name: 'Test Result', + value: 'testResult', + description: 'Historical test execution results', + }, + ], + default: 'run', + }, + + // Action + ...actionOperations, + ...actionFields, + + // Instance + ...instanceOperations, + ...instanceFields, + + // Project + ...projectOperations, + ...projectFields, + + // Run + ...runOperations, + ...runFields, + + // Signature + ...signatureOperations, + ...signatureFields, + + // Spec File + ...specFileOperations, + ...specFileFields, + + // Test + ...testOperations, + ...testFields, + + // Test Result + ...testResultOperations, + ...testResultFields, + ], + }; + + methods = { + listSearch, + }; +} diff --git a/packages/nodes-base/nodes/Currents/CurrentsTrigger.node.ts b/packages/nodes-base/nodes/Currents/CurrentsTrigger.node.ts new file mode 100644 index 00000000000..bc44a439f89 --- /dev/null +++ b/packages/nodes-base/nodes/Currents/CurrentsTrigger.node.ts @@ -0,0 +1,248 @@ +import type { + IHookFunctions, + INodeType, + INodeTypeDescription, + IWebhookFunctions, + IWebhookResponseData, +} from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; + +import { + createWebhook, + deleteWebhook, + findWebhookByUrl, + generateWebhookSecret, + updateWebhook, + verifyWebhook, +} from './CurrentsTriggerHelpers'; +import { projectRLC } from './descriptions/common.descriptions'; +import { listSearch } from './methods'; + +export class CurrentsTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Currents Trigger', + name: 'currentsTrigger', + icon: 'file:currents.svg', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["events"].join(", ")}}', + description: 'Starts the workflow when Currents events occur', + defaults: { + name: 'Currents Trigger', + }, + inputs: [], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'currentsApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + ...projectRLC, + }, + { + displayName: + 'Currents sends separate webhook events for each group in a run. If your run has multiple groups, you will receive separate events for each group.', + name: 'noticeGroups', + type: 'notice', + default: '', + }, + { + displayName: 'Events', + name: 'events', + type: 'multiOptions', + options: [ + { + name: 'Run Canceled', + value: 'RUN_CANCELED', + description: 'Triggered when a run is manually canceled', + }, + { + name: 'Run Finished', + value: 'RUN_FINISH', + description: 'Triggered when a run completes', + }, + { + name: 'Run Started', + value: 'RUN_START', + description: 'Triggered when a new run begins', + }, + { + name: 'Run Timeout', + value: 'RUN_TIMEOUT', + description: 'Triggered when a run exceeds the time limit', + }, + ], + required: true, + default: [], + description: 'The events to listen to', + }, + ], + }; + + methods = { + listSearch, + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + if (!webhookUrl) { + return false; + } + + const webhookData = this.getWorkflowStaticData('node'); + const projectId = this.getNodeParameter('projectId', '', { extractValue: true }) as string; + const events = this.getNodeParameter('events', []) as string[]; + + const existingWebhook = await findWebhookByUrl.call(this, projectId, webhookUrl); + + if (existingWebhook) { + webhookData.hookId = existingWebhook.hookId; + + // If secret is missing from static data, we need to recreate + if (!webhookData.webhookSecret) { + try { + await deleteWebhook.call(this, existingWebhook.hookId); + } catch (error) { + this.logger.debug('Failed to delete orphaned webhook during checkExists', { + hookId: existingWebhook.hookId, + error, + }); + } + return false; + } + + const currentEvents = existingWebhook.hookEvents ?? []; + const eventsMatch = + events.length === currentEvents.length && + events.every((e) => currentEvents.includes(e)); + + if (!eventsMatch) { + const headers = JSON.stringify({ + 'x-webhook-secret': webhookData.webhookSecret, + }); + await updateWebhook.call(this, existingWebhook.hookId, { + hookEvents: events, + headers, + }); + } + + return true; + } + + return false; + }, + + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + if (!webhookUrl) { + return false; + } + + const webhookData = this.getWorkflowStaticData('node'); + const projectId = this.getNodeParameter('projectId', '', { extractValue: true }) as string; + const events = this.getNodeParameter('events', []) as string[]; + const workflow = this.getWorkflow(); + + const webhookSecret = generateWebhookSecret(); + const label = `n8n workflow ${workflow.id ?? 'unknown'}`; + const headers = JSON.stringify({ + 'x-webhook-secret': webhookSecret, + }); + + const webhook = await createWebhook.call(this, projectId, { + url: webhookUrl, + hookEvents: events, + headers, + label, + }); + + webhookData.hookId = webhook.hookId; + webhookData.webhookSecret = webhookSecret; + + return true; + }, + + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + let hookId = webhookData.hookId as string | undefined; + + // Fallback: lookup webhook by URL if hookId missing from static data + if (!hookId) { + const webhookUrl = this.getNodeWebhookUrl('default'); + if (webhookUrl) { + try { + const projectId = this.getNodeParameter('projectId', '', { + extractValue: true, + }) as string; + if (projectId) { + const existingWebhook = await findWebhookByUrl.call(this, projectId, webhookUrl); + if (existingWebhook) { + hookId = existingWebhook.hookId; + } + } + } catch (error) { + this.logger.debug('Failed to lookup webhook by URL during delete', { + webhookUrl, + error, + }); + } + } + } + + if (hookId) { + try { + await deleteWebhook.call(this, hookId); + } catch (error) { + // Ignore 404 errors (webhook already deleted) + const statusCode = (error as { httpStatusCode?: number }).httpStatusCode; + if (statusCode !== 404) { + throw error; + } + } + delete webhookData.hookId; + delete webhookData.webhookSecret; + } + + return true; + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/require-await + async webhook(this: IWebhookFunctions): Promise { + if (!verifyWebhook.call(this)) { + const res = this.getResponseObject(); + res.status(401).send('Unauthorized').end(); + return { + noWebhookResponse: true, + }; + } + + const bodyData = this.getBodyData(); + const events = this.getNodeParameter('events', []) as string[]; + const eventType = typeof bodyData.event === 'string' ? bodyData.event : ''; + + if (events.length > 0 && !events.includes(eventType)) { + return { + webhookResponse: 'OK', + }; + } + + return { + workflowData: [this.helpers.returnJsonArray([bodyData])], + }; + } +} diff --git a/packages/nodes-base/nodes/Currents/CurrentsTriggerHelpers.ts b/packages/nodes-base/nodes/Currents/CurrentsTriggerHelpers.ts new file mode 100644 index 00000000000..0df17a5e293 --- /dev/null +++ b/packages/nodes-base/nodes/Currents/CurrentsTriggerHelpers.ts @@ -0,0 +1,191 @@ +import { randomBytes, timingSafeEqual } from 'crypto'; +import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow'; + +const CURRENTS_API_BASE = 'https://api.currents.dev/v1'; + +/** + * Maximum allowed age for a webhook request timestamp (5 minutes). + * Requests older than this are considered potential replay attacks. + */ +const MAX_TIMESTAMP_AGE_SECONDS = 300; + +/** + * Header name used for webhook secret validation. + */ +const WEBHOOK_SECRET_HEADER = 'x-webhook-secret'; + +/** + * Currents webhook object returned from the API. + */ +export interface CurrentsWebhook { + hookId: string; + projectId: string; + url: string; + headers?: string | null; + hookEvents: string[]; + label?: string | null; + createdAt?: string; + updatedAt?: string; +} + +/** + * Options for creating a Currents webhook. + */ +export interface CreateWebhookOptions { + url: string; + hookEvents?: string[]; + headers?: string; + label?: string; +} + +/** + * Generates a cryptographically secure random secret for webhook validation. + */ +export function generateWebhookSecret(): string { + return randomBytes(32).toString('hex'); +} + +/** + * Lists all webhooks for a project. + */ +export async function listWebhooks( + this: IHookFunctions, + projectId: string, +): Promise { + const response = await this.helpers.httpRequestWithAuthentication.call(this, 'currentsApi', { + method: 'GET', + url: `${CURRENTS_API_BASE}/webhooks`, + qs: { projectId }, + }); + + return (response.data as CurrentsWebhook[]) ?? []; +} + +/** + * Finds an existing webhook by URL for a project. + */ +export async function findWebhookByUrl( + this: IHookFunctions, + projectId: string, + webhookUrl: string, +): Promise { + const webhooks = await listWebhooks.call(this, projectId); + return webhooks.find((webhook) => webhook.url === webhookUrl); +} + +/** + * Creates a new webhook in Currents. + */ +export async function createWebhook( + this: IHookFunctions, + projectId: string, + options: CreateWebhookOptions, +): Promise { + const response = await this.helpers.httpRequestWithAuthentication.call(this, 'currentsApi', { + method: 'POST', + url: `${CURRENTS_API_BASE}/webhooks`, + qs: { projectId }, + body: { + url: options.url, + hookEvents: options.hookEvents ?? [], + headers: options.headers, + label: options.label, + }, + }); + + return response.data as CurrentsWebhook; +} + +/** + * Updates an existing webhook in Currents. + */ +export async function updateWebhook( + this: IHookFunctions, + hookId: string, + options: Partial, +): Promise { + const response = await this.helpers.httpRequestWithAuthentication.call(this, 'currentsApi', { + method: 'PUT', + url: `${CURRENTS_API_BASE}/webhooks/${hookId}`, + body: { + ...(options.url && { url: options.url }), + ...(options.hookEvents && { hookEvents: options.hookEvents }), + ...(options.headers && { headers: options.headers }), + ...(options.label && { label: options.label }), + }, + }); + + return response.data as CurrentsWebhook; +} + +/** + * Deletes a webhook from Currents. + */ +export async function deleteWebhook(this: IHookFunctions, hookId: string): Promise { + await this.helpers.httpRequestWithAuthentication.call(this, 'currentsApi', { + method: 'DELETE', + url: `${CURRENTS_API_BASE}/webhooks/${hookId}`, + }); +} + +/** + * Verifies the webhook request is recent and validates the secret. + * + * Uses auto-managed secret from workflow static data. + * + * Currents.dev includes an `x-timestamp` header with the epoch timestamp in milliseconds. + * This function validates that the timestamp is within an acceptable window to prevent + * replay attacks. + * + * @returns true if the request is valid, false otherwise + */ +export function verifyWebhook(this: IWebhookFunctions): boolean { + const req = this.getRequestObject(); + const headerData = this.getHeaderData(); + + // Check timestamp to prevent replay attacks (Currents sends milliseconds) + const timestampHeader = req.headers['x-timestamp']; + if (typeof timestampHeader === 'string') { + const requestTimeMs = parseInt(timestampHeader, 10); + if (isNaN(requestTimeMs)) { + return false; + } + const requestTimeSec = Math.floor(requestTimeMs / 1000); + const currentTimeSec = Math.floor(Date.now() / 1000); + const age = Math.abs(currentTimeSec - requestTimeSec); + + if (age > MAX_TIMESTAMP_AGE_SECONDS) { + return false; + } + } + + const webhookData = this.getWorkflowStaticData('node'); + const expectedSecret = webhookData.webhookSecret; + + if (typeof expectedSecret === 'string') { + const actualSecret = headerData[WEBHOOK_SECRET_HEADER]; + if (typeof actualSecret !== 'string') { + return false; + } + // Use constant-time comparison to prevent timing attacks + if ( + expectedSecret.length !== actualSecret.length || + !timingSafeEqual(Buffer.from(expectedSecret), Buffer.from(actualSecret)) + ) { + return false; + } + } + + return true; +} + +/** + * Validates that a millisecond timestamp is within the acceptable window. + * Exported separately for unit testing. + */ +export function isTimestampValid(timestampMs: number, currentTimeSec?: number): boolean { + const requestTimeSec = Math.floor(timestampMs / 1000); + const now = currentTimeSec ?? Math.floor(Date.now() / 1000); + const age = Math.abs(now - requestTimeSec); + return age <= MAX_TIMESTAMP_AGE_SECONDS; +} diff --git a/packages/nodes-base/nodes/Currents/currents.svg b/packages/nodes-base/nodes/Currents/currents.svg new file mode 100644 index 00000000000..6bb031b0937 --- /dev/null +++ b/packages/nodes-base/nodes/Currents/currents.svg @@ -0,0 +1 @@ +logo-bimi.svg \ No newline at end of file diff --git a/packages/nodes-base/nodes/Currents/descriptions/ActionDescription.ts b/packages/nodes-base/nodes/Currents/descriptions/ActionDescription.ts new file mode 100644 index 00000000000..c7ef1f41bed --- /dev/null +++ b/packages/nodes-base/nodes/Currents/descriptions/ActionDescription.ts @@ -0,0 +1,502 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const actionOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['action'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new action for a project', + routing: { + request: { + method: 'POST', + url: '/actions', + }, + }, + action: 'Create an action', + }, + { + name: 'Delete', + value: 'delete', + description: 'Archive an action (soft delete)', + routing: { + request: { + method: 'DELETE', + url: '=/actions/{{$parameter["actionId"]}}', + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', + }, + }, + ], + }, + }, + action: 'Delete an action', + }, + { + name: 'Disable', + value: 'disable', + description: 'Deactivate an active action', + routing: { + request: { + method: 'PUT', + url: '=/actions/{{$parameter["actionId"]}}/disable', + }, + }, + action: 'Disable an action', + }, + { + name: 'Enable', + value: 'enable', + description: 'Reactivate a disabled action', + routing: { + request: { + method: 'PUT', + url: '=/actions/{{$parameter["actionId"]}}/enable', + }, + }, + action: 'Enable an action', + }, + { + name: 'Get', + value: 'get', + description: 'Get a single action by ID', + routing: { + request: { + method: 'GET', + url: '=/actions/{{$parameter["actionId"]}}', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get an action', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many actions for a project', + routing: { + request: { + method: 'GET', + url: '/actions', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get many actions', + }, + { + name: 'Update', + value: 'update', + description: 'Update an existing action', + routing: { + request: { + method: 'PUT', + url: '=/actions/{{$parameter["actionId"]}}', + }, + }, + action: 'Update an action', + }, + ], + default: 'getAll', + }, +]; + +export const actionFields: INodeProperties[] = [ + // ---------------------------------- + // action:get, delete, enable, disable, update + // ---------------------------------- + { + displayName: 'Action ID', + name: 'actionId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['action'], + operation: ['get', 'delete', 'enable', 'disable', 'update'], + }, + }, + description: 'The ID of the action', + }, + + // ---------------------------------- + // action:getAll + // ---------------------------------- + { + displayName: 'Project', + name: 'projectId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + displayOptions: { + show: { + resource: ['action'], + operation: ['getAll'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'getProjects', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. abc123', + }, + ], + routing: { + send: { + type: 'query', + property: 'projectId', + value: '={{ $value }}', + }, + }, + description: 'The Currents project', + }, + + // ---------------------------------- + // action:create + // ---------------------------------- + { + displayName: 'Project', + name: 'projectId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + displayOptions: { + show: { + resource: ['action'], + operation: ['create'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'getProjects', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. abc123', + }, + ], + routing: { + send: { + type: 'body', + property: 'projectId', + value: '={{ $value }}', + }, + }, + description: 'The Currents project', + }, + + // ---------------------------------- + // action:getAll + // ---------------------------------- + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['action'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Search', + name: 'search', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'search', + }, + }, + description: 'Search actions by name (max 100 characters)', + }, + { + displayName: 'Status', + name: 'status', + type: 'multiOptions', + options: [ + { name: 'Active', value: 'active' }, + { name: 'Disabled', value: 'disabled' }, + ], + default: [], + routing: { + send: { + type: 'query', + property: 'status', + }, + }, + description: 'Filter by action status', + }, + ], + }, + + // ---------------------------------- + // action:create + // ---------------------------------- + { + displayName: 'Name', + name: 'name', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['action'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'name', + }, + }, + description: 'The name of the action (1-255 characters)', + }, + { + displayName: 'Action Type', + name: 'actionType', + type: 'options', + required: true, + options: [ + { name: 'Quarantine', value: 'quarantine', description: 'Quarantine matching tests' }, + { name: 'Skip', value: 'skip', description: 'Skip matching tests' }, + { name: 'Tag', value: 'tag', description: 'Add tags to matching tests' }, + ], + default: 'quarantine', + displayOptions: { + show: { + resource: ['action'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'action.type', + }, + }, + }, + { + displayName: 'Tags', + name: 'actionTags', + type: 'string', + default: '', + displayOptions: { + show: { + resource: ['action'], + operation: ['create'], + actionType: ['tag'], + }, + }, + routing: { + send: { + type: 'body', + property: 'action.tags', + value: '={{ $value.split(",").map(t => t.trim()).filter(t => t) }}', + }, + }, + description: 'Comma-separated list of tags to apply', + }, + { + displayName: 'Matcher Type', + name: 'matcherType', + type: 'options', + required: true, + options: [ + { name: 'Spec File Contains', value: 'specContains' }, + { name: 'Spec File Equals', value: 'specEquals' }, + { name: 'Test Signature', value: 'signature' }, + { name: 'Test Title Contains', value: 'titleContains' }, + { name: 'Test Title Equals', value: 'titleEquals' }, + ], + default: 'titleContains', + displayOptions: { + show: { + resource: ['action'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'matcher.type', + }, + }, + description: 'How to match tests for this action', + }, + { + displayName: 'Matcher Value', + name: 'matcherValue', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['action'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'matcher.value', + }, + }, + description: 'The value to match against (test title, spec file path, or signature)', + }, + { + displayName: 'Options', + name: 'createOptions', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['action'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'description', + }, + }, + description: 'A description for the action', + }, + { + displayName: 'Expires After', + name: 'expiresAfter', + type: 'dateTime', + default: '', + routing: { + send: { + type: 'body', + property: 'expiresAfter', + }, + }, + description: 'When the action should expire (ISO 8601 format)', + }, + ], + }, + + // ---------------------------------- + // action:update + // ---------------------------------- + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['action'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'name', + }, + }, + description: 'The name of the action (1-255 characters)', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'description', + }, + }, + description: 'A description for the action', + }, + { + displayName: 'Expires After', + name: 'expiresAfter', + type: 'dateTime', + default: '', + routing: { + send: { + type: 'body', + property: 'expiresAfter', + }, + }, + description: 'When the action should expire (ISO 8601 format)', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Currents/descriptions/InstanceDescription.ts b/packages/nodes-base/nodes/Currents/descriptions/InstanceDescription.ts new file mode 100644 index 00000000000..9478604f63a --- /dev/null +++ b/packages/nodes-base/nodes/Currents/descriptions/InstanceDescription.ts @@ -0,0 +1,57 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const instanceOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['instance'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a spec file execution instance with full test results', + routing: { + request: { + method: 'GET', + url: '=/instances/{{$parameter["instanceId"]}}', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get an instance', + }, + ], + default: 'get', + }, +]; + +export const instanceFields: INodeProperties[] = [ + { + displayName: 'Instance ID', + name: 'instanceId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['instance'], + operation: ['get'], + }, + }, + description: 'The ID of the spec file execution instance', + }, +]; diff --git a/packages/nodes-base/nodes/Currents/descriptions/ProjectDescription.ts b/packages/nodes-base/nodes/Currents/descriptions/ProjectDescription.ts new file mode 100644 index 00000000000..aef93a9666b --- /dev/null +++ b/packages/nodes-base/nodes/Currents/descriptions/ProjectDescription.ts @@ -0,0 +1,268 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const projectOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['project'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a project by ID', + routing: { + request: { + method: 'GET', + url: '=/projects/{{$parameter["projectId"]}}', + }, + }, + action: 'Get a project', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many projects', + routing: { + request: { + method: 'GET', + url: '/projects', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get many projects', + }, + { + name: 'Get Insights', + value: 'getInsights', + description: 'Get project insights and metrics', + routing: { + request: { + method: 'GET', + url: '=/projects/{{$parameter["projectId"]}}/insights', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get project insights', + }, + ], + default: 'getAll', + }, +]; + +export const projectFields: INodeProperties[] = [ + // ---------------------------------- + // project:get + // ---------------------------------- + { + displayName: 'Project', + name: 'projectId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + displayOptions: { + show: { + resource: ['project'], + operation: ['get', 'getInsights'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'getProjects', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. abc123', + }, + ], + description: 'The Currents project', + }, + + // ---------------------------------- + // project:getAll + // ---------------------------------- + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['project'], + operation: ['getAll'], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 50, + }, + default: 10, + routing: { + send: { + type: 'query', + property: 'limit', + }, + }, + description: 'Max number of results to return', + }, + + // ---------------------------------- + // project:getInsights + // ---------------------------------- + { + displayName: 'Date Start', + name: 'dateStart', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: ['project'], + operation: ['getInsights'], + }, + }, + routing: { + send: { + type: 'query', + property: 'date_start', + }, + }, + description: 'Start date for metrics (ISO 8601 format)', + }, + { + displayName: 'Date End', + name: 'dateEnd', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: ['project'], + operation: ['getInsights'], + }, + }, + routing: { + send: { + type: 'query', + property: 'date_end', + }, + }, + description: 'End date for metrics (ISO 8601 format)', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['project'], + operation: ['getInsights'], + }, + }, + options: [ + { + displayName: 'Authors', + name: 'authors', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'authors', + }, + }, + description: 'Filter by commit author names (comma-separated)', + }, + { + displayName: 'Branches', + name: 'branches', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'branches', + }, + }, + description: 'Filter by branch names (comma-separated)', + }, + { + displayName: 'Groups', + name: 'groups', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'groups', + }, + }, + description: 'Filter by group names (comma-separated)', + }, + { + displayName: 'Resolution', + name: 'resolution', + type: 'options', + options: [ + { name: '1 Hour', value: '1h' }, + { name: '1 Day', value: '1d' }, + { name: '1 Week', value: '1w' }, + ], + default: '1d', + routing: { + send: { + type: 'query', + property: 'resolution', + }, + }, + description: 'Time resolution for metrics', + }, + { + displayName: 'Tags', + name: 'tags', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'tags', + }, + }, + description: 'Filter by tags (comma-separated)', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Currents/descriptions/RunDescription.ts b/packages/nodes-base/nodes/Currents/descriptions/RunDescription.ts new file mode 100644 index 00000000000..cb791c37f65 --- /dev/null +++ b/packages/nodes-base/nodes/Currents/descriptions/RunDescription.ts @@ -0,0 +1,613 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const runOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['run'], + }, + }, + options: [ + { + name: 'Cancel', + value: 'cancel', + description: 'Cancel a run in progress', + routing: { + request: { + method: 'PUT', + url: '=/runs/{{$parameter["runId"]}}/cancel', + }, + }, + action: 'Cancel a run', + }, + { + name: 'Cancel by GitHub CI', + value: 'cancelGithub', + description: 'Cancel a run by GitHub Actions workflow run ID', + routing: { + request: { + method: 'PUT', + url: '/runs/cancel-ci/github', + }, + }, + action: 'Cancel a run by GitHub CI', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a run and all associated data', + routing: { + request: { + method: 'DELETE', + url: '=/runs/{{$parameter["runId"]}}', + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', + }, + }, + ], + }, + }, + action: 'Delete a run', + }, + { + name: 'Find', + value: 'find', + description: 'Find a run by project and filters', + routing: { + request: { + method: 'GET', + url: '/runs/find', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Find a run', + }, + { + name: 'Get', + value: 'get', + description: 'Get a run by ID', + routing: { + request: { + method: 'GET', + url: '=/runs/{{$parameter["runId"]}}', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get a run', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many runs for a project', + routing: { + request: { + method: 'GET', + url: '=/projects/{{$parameter["projectId"]}}/runs', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get many runs', + }, + { + name: 'Reset', + value: 'reset', + description: 'Reset failed specs for re-execution on specified machines', + routing: { + request: { + method: 'PUT', + url: '=/runs/{{$parameter["runId"]}}/reset', + }, + }, + action: 'Reset a run', + }, + ], + default: 'getAll', + }, +]; + +export const runFields: INodeProperties[] = [ + // ---------------------------------- + // run:get, cancel, delete, reset + // ---------------------------------- + { + displayName: 'Run ID', + name: 'runId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['run'], + operation: ['get', 'cancel', 'delete', 'reset'], + }, + }, + description: 'The ID of the run', + }, + + // ---------------------------------- + // run:reset + // ---------------------------------- + { + displayName: 'Machine IDs', + name: 'machineIds', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['run'], + operation: ['reset'], + }, + }, + routing: { + send: { + type: 'body', + property: 'machineId', + value: '={{ $value.split(",").map(id => id.trim()) }}', + }, + }, + description: 'Comma-separated list of machine identifiers to reset (1-63 items)', + }, + { + displayName: 'Options', + name: 'resetOptions', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['run'], + operation: ['reset'], + }, + }, + options: [ + { + displayName: 'Batched Orchestration', + name: 'isBatchedOr8n', + type: 'boolean', + default: false, + routing: { + send: { + type: 'body', + property: 'isBatchedOr8n', + }, + }, + description: 'Whether to enable batched orchestration', + }, + ], + }, + + // ---------------------------------- + // run:cancelGithub + // ---------------------------------- + { + displayName: 'GitHub Run ID', + name: 'githubRunId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['run'], + operation: ['cancelGithub'], + }, + }, + routing: { + send: { + type: 'body', + property: 'githubRunId', + }, + }, + description: 'The GitHub Actions workflow run ID', + }, + { + displayName: 'GitHub Run Attempt', + name: 'githubRunAttempt', + type: 'number', + required: true, + default: 1, + displayOptions: { + show: { + resource: ['run'], + operation: ['cancelGithub'], + }, + }, + routing: { + send: { + type: 'body', + property: 'githubRunAttempt', + }, + }, + description: 'The GitHub Actions workflow attempt number', + }, + { + displayName: 'Options', + name: 'cancelGithubOptions', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['run'], + operation: ['cancelGithub'], + }, + }, + options: [ + { + displayName: 'Project ID', + name: 'projectId', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'projectId', + }, + }, + description: 'Limit cancellation to a specific project', + }, + { + displayName: 'CI Build ID', + name: 'ciBuildId', + type: 'string', + default: '', + routing: { + send: { + type: 'body', + property: 'ciBuildId', + }, + }, + description: 'Limit cancellation to a specific CI build', + }, + ], + }, + + // ---------------------------------- + // run:find + // ---------------------------------- + { + displayName: 'Project', + name: 'projectId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + displayOptions: { + show: { + resource: ['run'], + operation: ['find', 'getAll'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'getProjects', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. abc123', + }, + ], + routing: { + send: { + type: 'query', + property: 'projectId', + value: '={{ $value }}', + }, + }, + description: 'The Currents project', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['run'], + operation: ['find'], + }, + }, + options: [ + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'branch', + }, + }, + description: 'Filter by git branch name', + }, + { + displayName: 'CI Build ID', + name: 'ciBuildId', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'ciBuildId', + }, + }, + description: 'Filter by CI build ID', + }, + { + displayName: 'Tag', + name: 'tag', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'tag', + }, + }, + description: 'Filter by tag', + }, + ], + }, + + // ---------------------------------- + // run:getAll + // ---------------------------------- + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['run'], + operation: ['getAll'], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 50, + }, + default: 10, + routing: { + send: { + type: 'query', + property: 'limit', + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['run'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Authors', + name: 'author', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'author', + }, + }, + description: 'Filter by git commit author names (comma-separated for multiple)', + }, + { + displayName: 'Branch', + name: 'branch', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'branch', + }, + }, + description: 'Filter by git branch name', + }, + { + displayName: 'Completion State', + name: 'completionState', + type: 'multiOptions', + options: [ + { name: 'Canceled', value: 'CANCELED' }, + { name: 'Complete', value: 'COMPLETE' }, + { name: 'In Progress', value: 'IN_PROGRESS' }, + { name: 'Timeout', value: 'TIMEOUT' }, + ], + default: [], + routing: { + send: { + type: 'query', + property: 'completion_state', + }, + }, + description: 'Filter by completion state', + }, + { + displayName: 'Date End', + name: 'dateEnd', + type: 'dateTime', + default: '', + routing: { + send: { + type: 'query', + property: 'date_end', + }, + }, + description: 'Filter runs created before this date', + }, + { + displayName: 'Date Start', + name: 'dateStart', + type: 'dateTime', + default: '', + routing: { + send: { + type: 'query', + property: 'date_start', + }, + }, + description: 'Filter runs created on or after this date', + }, + { + displayName: 'Search', + name: 'search', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'search', + }, + }, + description: 'Search by ciBuildId or commit message (max 200 characters)', + }, + { + displayName: 'Status', + name: 'status', + type: 'multiOptions', + options: [ + { name: 'Failed', value: 'FAILED' }, + { name: 'Failing', value: 'FAILING' }, + { name: 'Passed', value: 'PASSED' }, + { name: 'Running', value: 'RUNNING' }, + ], + default: [], + routing: { + send: { + type: 'query', + property: 'status', + }, + }, + description: 'Filter by run status', + }, + { + displayName: 'Tags', + name: 'tag', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'tag', + }, + }, + description: 'Filter by tags (comma-separated for multiple)', + }, + { + displayName: 'Tag Operator', + name: 'tagOperator', + type: 'options', + options: [ + { name: 'AND', value: 'AND', description: 'All tags must be present' }, + { name: 'OR', value: 'OR', description: 'Any tag must be present' }, + ], + default: 'AND', + routing: { + send: { + type: 'query', + property: 'tag_operator', + }, + }, + description: 'Logical operator for tag filtering', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['run'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Starting After', + name: 'startingAfter', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'starting_after', + }, + }, + description: 'Cursor for forward pagination (use cursor from previous response)', + }, + { + displayName: 'Ending Before', + name: 'endingBefore', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'ending_before', + }, + }, + description: 'Cursor for backward pagination (use cursor from previous response)', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Currents/descriptions/SignatureDescription.ts b/packages/nodes-base/nodes/Currents/descriptions/SignatureDescription.ts new file mode 100644 index 00000000000..0d75e9c16fd --- /dev/null +++ b/packages/nodes-base/nodes/Currents/descriptions/SignatureDescription.ts @@ -0,0 +1,128 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const signatureOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['signature'], + }, + }, + options: [ + { + name: 'Generate', + value: 'generate', + description: 'Generate a unique test signature', + routing: { + request: { + method: 'POST', + url: '/signature/test', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Generate a signature', + }, + ], + default: 'generate', + }, +]; + +export const signatureFields: INodeProperties[] = [ + // ---------------------------------- + // signature:generate + // ---------------------------------- + { + displayName: 'Project', + name: 'projectId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + displayOptions: { + show: { + resource: ['signature'], + operation: ['generate'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'getProjects', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. abc123', + }, + ], + routing: { + send: { + type: 'body', + property: 'projectId', + value: '={{ $value }}', + }, + }, + description: 'The Currents project', + }, + { + displayName: 'Spec File Path', + name: 'specFilePath', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['signature'], + operation: ['generate'], + }, + }, + routing: { + send: { + type: 'body', + property: 'specFilePath', + }, + }, + placeholder: 'e.g., tests/e2e/login.spec.ts', + description: 'The complete path to the spec file', + }, + { + displayName: 'Test Title', + name: 'testTitle', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['signature'], + operation: ['generate'], + }, + }, + routing: { + send: { + type: 'body', + property: 'testTitle', + }, + }, + placeholder: 'e.g., should login with valid credentials', + description: + 'The test title. For nested describe blocks, use " > " as separator (e.g., "Login > should login with valid credentials").', + }, +]; diff --git a/packages/nodes-base/nodes/Currents/descriptions/SpecFileDescription.ts b/packages/nodes-base/nodes/Currents/descriptions/SpecFileDescription.ts new file mode 100644 index 00000000000..799d1f5919a --- /dev/null +++ b/packages/nodes-base/nodes/Currents/descriptions/SpecFileDescription.ts @@ -0,0 +1,307 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const specFileOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['specFile'], + }, + }, + options: [ + { + name: 'Get Many', + value: 'getAll', + description: 'Get aggregated spec file metrics for a project', + routing: { + request: { + method: 'GET', + url: '=/spec-files/{{$parameter["projectId"]}}', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get many spec files', + }, + ], + default: 'getAll', + }, +]; + +export const specFileFields: INodeProperties[] = [ + // ---------------------------------- + // specFile:getAll + // ---------------------------------- + { + displayName: 'Project', + name: 'projectId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + displayOptions: { + show: { + resource: ['specFile'], + operation: ['getAll'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'getProjects', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. abc123', + }, + ], + description: 'The Currents project', + }, + { + displayName: 'Date Start', + name: 'dateStart', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: ['specFile'], + operation: ['getAll'], + }, + }, + routing: { + send: { + type: 'query', + property: 'date_start', + }, + }, + description: 'Start date for metrics (ISO 8601 format)', + }, + { + displayName: 'Date End', + name: 'dateEnd', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: ['specFile'], + operation: ['getAll'], + }, + }, + routing: { + send: { + type: 'query', + property: 'date_end', + }, + }, + description: 'End date for metrics (ISO 8601 format)', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['specFile'], + operation: ['getAll'], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 50, + }, + default: 50, + routing: { + send: { + type: 'query', + property: 'limit', + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['specFile'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Authors', + name: 'authors', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'authors', + }, + }, + description: 'Filter by git author names (comma-separated for multiple)', + }, + { + displayName: 'Branches', + name: 'branches', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'branches', + }, + }, + description: 'Filter by branch names (comma-separated for multiple)', + }, + { + displayName: 'Groups', + name: 'groups', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'groups', + }, + }, + description: 'Filter by group names (comma-separated for multiple)', + }, + { + displayName: 'Spec Name', + name: 'specNameFilter', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'specNameFilter', + }, + }, + description: 'Filter spec files by name (partial match)', + }, + { + displayName: 'Tags', + name: 'tags', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'tags', + }, + }, + description: 'Filter by tags (comma-separated for multiple)', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['specFile'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Include Failed in Duration', + name: 'includeFailedInDuration', + type: 'boolean', + default: false, + routing: { + send: { + type: 'query', + property: 'includeFailedInDuration', + }, + }, + description: 'Whether to include failed executions in duration calculation', + }, + { + displayName: 'Order By', + name: 'order', + type: 'options', + options: [ + { name: 'Average Duration', value: 'avgDuration' }, + { name: 'Failed Executions', value: 'failedExecutions' }, + { name: 'Failure Rate', value: 'failureRate' }, + { name: 'Flake Rate', value: 'flakeRate' }, + { name: 'Flaky Executions', value: 'flakyExecutions' }, + { name: 'Fully Reported', value: 'fullyReported' }, + { name: 'Overall Executions', value: 'overallExecutions' }, + { name: 'Suite Size', value: 'suiteSize' }, + { name: 'Timeout Executions', value: 'timeoutExecutions' }, + { name: 'Timeout Rate', value: 'timeoutRate' }, + ], + default: 'avgDuration', + routing: { + send: { + type: 'query', + property: 'order', + }, + }, + description: 'The field to order results by', + }, + { + displayName: 'Sort Direction', + name: 'dir', + type: 'options', + options: [ + { name: 'Ascending', value: 'asc' }, + { name: 'Descending', value: 'desc' }, + ], + default: 'desc', + routing: { + send: { + type: 'query', + property: 'dir', + }, + }, + description: 'The direction to sort results', + }, + { + displayName: 'Page', + name: 'page', + type: 'number', + typeOptions: { + minValue: 0, + }, + default: 0, + routing: { + send: { + type: 'query', + property: 'page', + }, + }, + description: 'Page number (0-indexed)', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Currents/descriptions/TestDescription.ts b/packages/nodes-base/nodes/Currents/descriptions/TestDescription.ts new file mode 100644 index 00000000000..7ee65f7672a --- /dev/null +++ b/packages/nodes-base/nodes/Currents/descriptions/TestDescription.ts @@ -0,0 +1,342 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const testOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['test'], + }, + }, + options: [ + { + name: 'Get Many', + value: 'getAll', + description: 'Get aggregated test metrics for a project', + routing: { + request: { + method: 'GET', + url: '=/tests/{{$parameter["projectId"]}}', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get many tests', + }, + ], + default: 'getAll', + }, +]; + +export const testFields: INodeProperties[] = [ + // ---------------------------------- + // test:getAll + // ---------------------------------- + { + displayName: 'Project', + name: 'projectId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + displayOptions: { + show: { + resource: ['test'], + operation: ['getAll'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'getProjects', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. abc123', + }, + ], + description: 'The Currents project', + }, + { + displayName: 'Date Start', + name: 'dateStart', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: ['test'], + operation: ['getAll'], + }, + }, + routing: { + send: { + type: 'query', + property: 'date_start', + }, + }, + description: 'Start date for metrics (ISO 8601 format)', + }, + { + displayName: 'Date End', + name: 'dateEnd', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: ['test'], + operation: ['getAll'], + }, + }, + routing: { + send: { + type: 'query', + property: 'date_end', + }, + }, + description: 'End date for metrics (ISO 8601 format)', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['test'], + operation: ['getAll'], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 50, + }, + default: 50, + routing: { + send: { + type: 'query', + property: 'limit', + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['test'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Authors', + name: 'authors', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'authors', + }, + }, + description: 'Filter by git author names (comma-separated for multiple)', + }, + { + displayName: 'Branches', + name: 'branches', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'branches', + }, + }, + description: 'Filter by branch names (comma-separated for multiple)', + }, + { + displayName: 'Groups', + name: 'groups', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'groups', + }, + }, + description: 'Filter by group names (comma-separated for multiple)', + }, + { + displayName: 'Minimum Executions', + name: 'minExecutions', + type: 'number', + typeOptions: { + minValue: 1, + }, + default: 1, + routing: { + send: { + type: 'query', + property: 'min_executions', + }, + }, + description: 'Minimum number of executions to include a test', + }, + { + displayName: 'Spec File', + name: 'spec', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'spec', + }, + }, + description: 'Filter tests by spec file name (partial match)', + }, + { + displayName: 'Tags', + name: 'tags', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'tags', + }, + }, + description: 'Filter by tags (comma-separated for multiple)', + }, + { + displayName: 'Test State', + name: 'testState', + type: 'multiOptions', + options: [ + { name: 'Failed', value: 'failed' }, + { name: 'Passed', value: 'passed' }, + { name: 'Pending', value: 'pending' }, + { name: 'Skipped', value: 'skipped' }, + ], + default: [], + routing: { + send: { + type: 'query', + property: 'test_state', + }, + }, + description: 'Filter by test state', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'title', + }, + }, + description: 'Filter tests by title (partial match)', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['test'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Order By', + name: 'order', + type: 'options', + options: [ + { name: 'Duration', value: 'duration' }, + { name: 'Duration (Weighted)', value: 'duration_x_samples' }, + { name: 'Executions', value: 'executions' }, + { name: 'Failure Rate Delta', value: 'failure_rate_delta' }, + { name: 'Failures', value: 'failures' }, + { name: 'Flakiness', value: 'flakiness' }, + { name: 'Flakiness (Weighted)', value: 'flakiness_x_samples' }, + { name: 'Flakiness Rate Delta', value: 'flakiness_rate_delta' }, + { name: 'Passes', value: 'passes' }, + { name: 'Title', value: 'title' }, + ], + default: 'title', + routing: { + send: { + type: 'query', + property: 'order', + }, + }, + description: 'The field to order results by', + }, + { + displayName: 'Sort Direction', + name: 'dir', + type: 'options', + options: [ + { name: 'Ascending', value: 'asc' }, + { name: 'Descending', value: 'desc' }, + ], + default: 'desc', + routing: { + send: { + type: 'query', + property: 'dir', + }, + }, + description: 'The direction to sort results', + }, + { + displayName: 'Page', + name: 'page', + type: 'number', + typeOptions: { + minValue: 0, + }, + default: 0, + routing: { + send: { + type: 'query', + property: 'page', + }, + }, + description: 'Page number (0-indexed)', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Currents/descriptions/TestResultDescription.ts b/packages/nodes-base/nodes/Currents/descriptions/TestResultDescription.ts new file mode 100644 index 00000000000..aeb09fd5f23 --- /dev/null +++ b/packages/nodes-base/nodes/Currents/descriptions/TestResultDescription.ts @@ -0,0 +1,251 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const testResultOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['testResult'], + }, + }, + options: [ + { + name: 'Get Many', + value: 'getAll', + description: 'Get historical test execution results for a specific test signature', + routing: { + request: { + method: 'GET', + url: '=/test-results/{{$parameter["signature"]}}', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get test results', + }, + ], + default: 'getAll', + }, +]; + +export const testResultFields: INodeProperties[] = [ + // ---------------------------------- + // testResult:getAll + // ---------------------------------- + { + displayName: 'Test Signature', + name: 'signature', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['testResult'], + operation: ['getAll'], + }, + }, + description: + 'The unique test signature. Use the Signature resource to generate this from project ID, spec file path, and test title.', + }, + { + displayName: 'Date Start', + name: 'dateStart', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: ['testResult'], + operation: ['getAll'], + }, + }, + routing: { + send: { + type: 'query', + property: 'date_start', + }, + }, + description: 'Start date for results (ISO 8601 format)', + }, + { + displayName: 'Date End', + name: 'dateEnd', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: ['testResult'], + operation: ['getAll'], + }, + }, + routing: { + send: { + type: 'query', + property: 'date_end', + }, + }, + description: 'End date for results (ISO 8601 format)', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['testResult'], + operation: ['getAll'], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 10, + routing: { + send: { + type: 'query', + property: 'limit', + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['testResult'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Branches', + name: 'branch', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'branch', + }, + }, + description: 'Filter by git branch names (comma-separated for multiple)', + }, + { + displayName: 'Git Authors', + name: 'gitAuthor', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'git_author', + }, + }, + description: 'Filter by git author names (comma-separated for multiple)', + }, + { + displayName: 'Groups', + name: 'group', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'group', + }, + }, + description: 'Filter by run groups (comma-separated for multiple)', + }, + { + displayName: 'Status', + name: 'status', + type: 'multiOptions', + options: [ + { name: 'Failed', value: 'failed' }, + { name: 'Passed', value: 'passed' }, + { name: 'Pending', value: 'pending' }, + { name: 'Skipped', value: 'skipped' }, + ], + default: [], + routing: { + send: { + type: 'query', + property: 'status', + }, + }, + description: 'Filter by test status', + }, + { + displayName: 'Tags', + name: 'tag', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'tag', + }, + }, + description: 'Filter by run tags (comma-separated for multiple)', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: ['testResult'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Starting After', + name: 'startingAfter', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'starting_after', + }, + }, + description: 'Cursor for forward pagination', + }, + { + displayName: 'Ending Before', + name: 'endingBefore', + type: 'string', + default: '', + routing: { + send: { + type: 'query', + property: 'ending_before', + }, + }, + description: 'Cursor for backward pagination', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Currents/descriptions/common.descriptions.ts b/packages/nodes-base/nodes/Currents/descriptions/common.descriptions.ts new file mode 100644 index 00000000000..6f11068efc5 --- /dev/null +++ b/packages/nodes-base/nodes/Currents/descriptions/common.descriptions.ts @@ -0,0 +1,28 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const projectRLC: INodeProperties = { + displayName: 'Project', + name: 'projectId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'The Currents project', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'getProjects', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. abc123', + }, + ], +}; diff --git a/packages/nodes-base/nodes/Currents/methods/index.ts b/packages/nodes-base/nodes/Currents/methods/index.ts new file mode 100644 index 00000000000..07eaa29849d --- /dev/null +++ b/packages/nodes-base/nodes/Currents/methods/index.ts @@ -0,0 +1,5 @@ +import { getProjects } from './listSearch'; + +export const listSearch = { + getProjects, +}; diff --git a/packages/nodes-base/nodes/Currents/methods/listSearch.ts b/packages/nodes-base/nodes/Currents/methods/listSearch.ts new file mode 100644 index 00000000000..daa3b029616 --- /dev/null +++ b/packages/nodes-base/nodes/Currents/methods/listSearch.ts @@ -0,0 +1,33 @@ +import type { + IDataObject, + ILoadOptionsFunctions, + INodeListSearchItems, + INodeListSearchResult, +} from 'n8n-workflow'; + +export async function getProjects( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const response = await this.helpers.httpRequestWithAuthentication.call(this, 'currentsApi', { + method: 'GET', + url: 'https://api.currents.dev/v1/projects', + }); + + const projects: IDataObject[] = response.data ?? []; + + const results: INodeListSearchItems[] = projects + .filter( + (project) => + !filter || + (project.name as string)?.toLowerCase().includes(filter.toLowerCase()) || + (project.projectId as string)?.toLowerCase().includes(filter.toLowerCase()), + ) + .map((project) => ({ + name: (project.name as string) ?? (project.projectId as string), + value: project.projectId as string, + })) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + + return { results }; +} diff --git a/packages/nodes-base/nodes/Currents/test/Currents.structure.test.ts b/packages/nodes-base/nodes/Currents/test/Currents.structure.test.ts new file mode 100644 index 00000000000..b6777714f4b --- /dev/null +++ b/packages/nodes-base/nodes/Currents/test/Currents.structure.test.ts @@ -0,0 +1,94 @@ +import { CurrentsApi } from '../../../credentials/CurrentsApi.credentials'; +import { Currents } from '../Currents.node'; +import { actionOperations, actionFields } from '../descriptions/ActionDescription'; +import { projectRLC } from '../descriptions/common.descriptions'; +import { instanceOperations, instanceFields } from '../descriptions/InstanceDescription'; +import { projectOperations, projectFields } from '../descriptions/ProjectDescription'; +import { runOperations, runFields } from '../descriptions/RunDescription'; +import { signatureOperations, signatureFields } from '../descriptions/SignatureDescription'; +import { specFileOperations, specFileFields } from '../descriptions/SpecFileDescription'; +import { testOperations, testFields } from '../descriptions/TestDescription'; +import { testResultOperations, testResultFields } from '../descriptions/TestResultDescription'; +import { listSearch } from '../methods'; + +describe('Currents Node Structure', () => { + describe('Currents class', () => { + it('should be a valid node class', () => { + const node = new Currents(); + expect(node.description).toBeDefined(); + expect(node.description.name).toBe('currents'); + expect(node.description.displayName).toBe('Currents'); + }); + + it('should have correct credentials', () => { + const node = new Currents(); + expect(node.description.credentials).toContainEqual( + expect.objectContaining({ name: 'currentsApi' }), + ); + }); + + it('should have all resource types', () => { + const node = new Currents(); + const resourceProperty = node.description.properties.find((p) => p.name === 'resource'); + expect(resourceProperty).toBeDefined(); + + const options = resourceProperty?.options as Array<{ value: string }>; + const resourceValues = options?.map((o) => o.value) ?? []; + + expect(resourceValues).toContain('action'); + expect(resourceValues).toContain('instance'); + expect(resourceValues).toContain('project'); + expect(resourceValues).toContain('run'); + expect(resourceValues).toContain('signature'); + expect(resourceValues).toContain('specFile'); + expect(resourceValues).toContain('test'); + expect(resourceValues).toContain('testResult'); + }); + }); + + describe('Credentials', () => { + it('should be a valid credential class', () => { + const cred = new CurrentsApi(); + expect(cred.name).toBe('currentsApi'); + expect(cred.displayName).toBe('Currents API'); + expect(cred.properties).toBeDefined(); + expect(Array.isArray(cred.properties)).toBe(true); + }); + }); + + describe('Methods', () => { + it('should export listSearch with getProjects', () => { + expect(listSearch).toBeDefined(); + expect(listSearch.getProjects).toBeDefined(); + expect(typeof listSearch.getProjects).toBe('function'); + }); + }); + + describe('Description exports', () => { + const descriptionPairs = [ + { name: 'action', operations: actionOperations, fields: actionFields }, + { name: 'instance', operations: instanceOperations, fields: instanceFields }, + { name: 'project', operations: projectOperations, fields: projectFields }, + { name: 'run', operations: runOperations, fields: runFields }, + { name: 'signature', operations: signatureOperations, fields: signatureFields }, + { name: 'specFile', operations: specFileOperations, fields: specFileFields }, + { name: 'test', operations: testOperations, fields: testFields }, + { name: 'testResult', operations: testResultOperations, fields: testResultFields }, + ]; + + it.each(descriptionPairs)( + '$name should export valid operations and fields arrays', + ({ operations, fields }) => { + expect(Array.isArray(operations)).toBe(true); + expect(operations.length).toBeGreaterThan(0); + expect(Array.isArray(fields)).toBe(true); + }, + ); + + it('should export projectRLC as a valid resource locator', () => { + expect(projectRLC).toBeDefined(); + expect(projectRLC.name).toBe('projectId'); + expect(projectRLC.type).toBe('resourceLocator'); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Currents/test/CurrentsTrigger.test.ts b/packages/nodes-base/nodes/Currents/test/CurrentsTrigger.test.ts new file mode 100644 index 00000000000..52d34ec1de3 --- /dev/null +++ b/packages/nodes-base/nodes/Currents/test/CurrentsTrigger.test.ts @@ -0,0 +1,147 @@ +import type { IDataObject, IWebhookFunctions } from 'n8n-workflow'; + +import { CurrentsTrigger } from '../CurrentsTrigger.node'; + +// Mock the helper module +jest.mock('../CurrentsTriggerHelpers', () => ({ + verifyWebhook: jest.fn(), +})); + +import { verifyWebhook } from '../CurrentsTriggerHelpers'; + +describe('CurrentsTrigger', () => { + let trigger: CurrentsTrigger; + let mockWebhookFunctions: Partial; + let mockResponse: { status: jest.Mock; send: jest.Mock; end: jest.Mock }; + + beforeEach(() => { + trigger = new CurrentsTrigger(); + mockResponse = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + }; + + mockWebhookFunctions = { + getBodyData: jest.fn(), + getNodeParameter: jest.fn(), + getResponseObject: jest.fn().mockReturnValue(mockResponse), + helpers: { + returnJsonArray: jest.fn((data) => data), + } as unknown as IWebhookFunctions['helpers'], + }; + + (verifyWebhook as jest.Mock).mockReturnValue(true); + }); + + describe('webhook', () => { + it('should return 401 when verification fails', async () => { + (verifyWebhook as jest.Mock).mockReturnValue(false); + + const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.send).toHaveBeenCalledWith('Unauthorized'); + expect(result).toEqual({ noWebhookResponse: true }); + }); + + it('should trigger workflow when event matches selected events', async () => { + const bodyData: IDataObject = { + event: 'RUN_FINISH', + runUrl: 'https://app.currents.dev/run/123', + buildId: 'build-456', + }; + + (mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue(bodyData); + (mockWebhookFunctions.getNodeParameter as jest.Mock).mockReturnValue([ + 'RUN_FINISH', + 'RUN_START', + ]); + + const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions); + + expect(result.workflowData).toBeDefined(); + expect(mockWebhookFunctions.helpers!.returnJsonArray).toHaveBeenCalledWith([bodyData]); + }); + + it('should acknowledge but not trigger when event does not match', async () => { + const bodyData: IDataObject = { + event: 'RUN_TIMEOUT', + runUrl: 'https://app.currents.dev/run/123', + }; + + (mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue(bodyData); + (mockWebhookFunctions.getNodeParameter as jest.Mock).mockReturnValue([ + 'RUN_FINISH', + 'RUN_START', + ]); + + const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions); + + expect(result).toEqual({ webhookResponse: 'OK' }); + expect(result.workflowData).toBeUndefined(); + }); + + it('should trigger workflow for all events when no filter is set', async () => { + const bodyData: IDataObject = { + event: 'RUN_CANCELED', + runUrl: 'https://app.currents.dev/run/123', + }; + + (mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue(bodyData); + (mockWebhookFunctions.getNodeParameter as jest.Mock).mockReturnValue([]); + + const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions); + + expect(result.workflowData).toBeDefined(); + }); + + it('should pass full webhook payload to workflow', async () => { + const bodyData: IDataObject = { + event: 'RUN_FINISH', + runUrl: 'https://app.currents.dev/run/123', + buildId: 'build-456', + groupId: 'group-1', + tags: ['smoke', 'regression'], + commit: { + sha: 'abc123', + branch: 'main', + authorName: 'Test Author', + }, + failures: 0, + passes: 42, + flaky: 2, + }; + + (mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue(bodyData); + (mockWebhookFunctions.getNodeParameter as jest.Mock).mockReturnValue(['RUN_FINISH']); + + const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions); + + expect(mockWebhookFunctions.helpers!.returnJsonArray).toHaveBeenCalledWith([bodyData]); + expect(result.workflowData).toBeDefined(); + }); + }); + + describe('description', () => { + it('should have correct node metadata', () => { + expect(trigger.description.displayName).toBe('Currents Trigger'); + expect(trigger.description.name).toBe('currentsTrigger'); + expect(trigger.description.group).toContain('trigger'); + }); + + it('should have all webhook event options', () => { + const eventsProperty = trigger.description.properties.find((p) => p.name === 'events'); + expect(eventsProperty).toBeDefined(); + expect(eventsProperty?.type).toBe('multiOptions'); + + const options = (eventsProperty as { options?: Array<{ value: string }> })?.options ?? []; + const eventValues = options.map((o) => o.value); + + expect(eventValues).toContain('RUN_START'); + expect(eventValues).toContain('RUN_FINISH'); + expect(eventValues).toContain('RUN_TIMEOUT'); + expect(eventValues).toContain('RUN_CANCELED'); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Currents/test/CurrentsTriggerHelpers.test.ts b/packages/nodes-base/nodes/Currents/test/CurrentsTriggerHelpers.test.ts new file mode 100644 index 00000000000..3b3f4aadb05 --- /dev/null +++ b/packages/nodes-base/nodes/Currents/test/CurrentsTriggerHelpers.test.ts @@ -0,0 +1,502 @@ +import type { IDataObject, IHookFunctions, IWebhookFunctions } from 'n8n-workflow'; + +import { + createWebhook, + deleteWebhook, + findWebhookByUrl, + generateWebhookSecret, + isTimestampValid, + listWebhooks, + updateWebhook, + verifyWebhook, +} from '../CurrentsTriggerHelpers'; + +describe('CurrentsTriggerHelpers', () => { + describe('isTimestampValid', () => { + it('should return true for current timestamp in milliseconds', () => { + const nowSec = Math.floor(Date.now() / 1000); + const nowMs = nowSec * 1000; + expect(isTimestampValid(nowMs, nowSec)).toBe(true); + }); + + it('should return true for timestamp within 5 minutes', () => { + const nowSec = Math.floor(Date.now() / 1000); + const fourMinutesAgoMs = (nowSec - 240) * 1000; + expect(isTimestampValid(fourMinutesAgoMs, nowSec)).toBe(true); + }); + + it('should return false for timestamp older than 5 minutes', () => { + const nowSec = Math.floor(Date.now() / 1000); + const sixMinutesAgoMs = (nowSec - 360) * 1000; + expect(isTimestampValid(sixMinutesAgoMs, nowSec)).toBe(false); + }); + + it('should return false for timestamp from the future beyond tolerance', () => { + const nowSec = Math.floor(Date.now() / 1000); + const sixMinutesInFutureMs = (nowSec + 360) * 1000; + expect(isTimestampValid(sixMinutesInFutureMs, nowSec)).toBe(false); + }); + + it('should return true for timestamp at exactly 5 minutes', () => { + const nowSec = Math.floor(Date.now() / 1000); + const fiveMinutesAgoMs = (nowSec - 300) * 1000; + expect(isTimestampValid(fiveMinutesAgoMs, nowSec)).toBe(true); + }); + }); + + describe('generateWebhookSecret', () => { + it('should generate a 64-character hex string', () => { + const secret = generateWebhookSecret(); + expect(secret).toHaveLength(64); + expect(/^[0-9a-f]+$/.test(secret)).toBe(true); + }); + + it('should generate unique secrets', () => { + const secret1 = generateWebhookSecret(); + const secret2 = generateWebhookSecret(); + expect(secret1).not.toBe(secret2); + }); + }); + + describe('verifyWebhook', () => { + let mockWebhookFunctions: Partial; + + beforeEach(() => { + mockWebhookFunctions = { + getRequestObject: jest.fn(), + getHeaderData: jest.fn(), + getWorkflowStaticData: jest.fn(), + }; + }); + + it('should return true when no secret in static data (no verification)', () => { + const nowMs = Date.now(); + + (mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({ + headers: { 'x-timestamp': String(nowMs) }, + }); + (mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({}); + (mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({}); + + const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions); + expect(result).toBe(true); + }); + + it('should return false when timestamp is stale', () => { + const tenMinutesAgoMs = Date.now() - 600 * 1000; + + (mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({ + headers: { 'x-timestamp': String(tenMinutesAgoMs) }, + }); + (mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({}); + (mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({}); + + const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions); + expect(result).toBe(false); + }); + + it('should return false when timestamp is invalid/non-numeric', () => { + (mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({ + headers: { 'x-timestamp': 'not-a-number' }, + }); + (mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({}); + (mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({}); + + const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions); + expect(result).toBe(false); + }); + + it('should return true when secret matches from static data', () => { + const nowMs = Date.now(); + const secret = 'auto-generated-secret'; + + (mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({ + headers: { 'x-timestamp': String(nowMs) }, + }); + (mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({ + 'x-webhook-secret': secret, + }); + (mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({ + webhookSecret: secret, + } as IDataObject); + + const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions); + expect(result).toBe(true); + }); + + it('should return false when secret does not match (different length)', () => { + const nowMs = Date.now(); + + (mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({ + headers: { 'x-timestamp': String(nowMs) }, + }); + (mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({ + 'x-webhook-secret': 'wrong-secret', + }); + (mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({ + webhookSecret: 'correct-secret', + } as IDataObject); + + const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions); + expect(result).toBe(false); + }); + + it('should return false when secret does not match (same length)', () => { + const nowMs = Date.now(); + + (mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({ + headers: { 'x-timestamp': String(nowMs) }, + }); + (mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({ + 'x-webhook-secret': 'wrong-secret-aa', + }); + (mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({ + webhookSecret: 'correct-secret', + } as IDataObject); + + const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions); + expect(result).toBe(false); + }); + + it('should return false when secret header is missing but expected', () => { + const nowMs = Date.now(); + + (mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({ + headers: { 'x-timestamp': String(nowMs) }, + }); + (mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({}); + (mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({ + webhookSecret: 'expected-secret', + } as IDataObject); + + const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions); + expect(result).toBe(false); + }); + + it('should handle missing timestamp header gracefully', () => { + (mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({ + headers: {}, + }); + (mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({}); + (mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({}); + + const result = verifyWebhook.call(mockWebhookFunctions as IWebhookFunctions); + expect(result).toBe(true); + }); + }); + + describe('listWebhooks', () => { + let mockHookFunctions: Partial; + + beforeEach(() => { + mockHookFunctions = { + helpers: { + httpRequestWithAuthentication: jest.fn(), + } as unknown as IHookFunctions['helpers'], + }; + }); + + it('should return webhooks from API response', async () => { + const mockWebhooks = [ + { + hookId: 'hook-1', + projectId: 'project-123', + url: 'https://example.com/webhook1', + hookEvents: ['RUN_FINISH'], + }, + { + hookId: 'hook-2', + projectId: 'project-123', + url: 'https://example.com/webhook2', + hookEvents: ['RUN_START', 'RUN_FINISH'], + }, + ]; + + (mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({ + data: mockWebhooks, + }); + + const result = await listWebhooks.call(mockHookFunctions as IHookFunctions, 'project-123'); + + expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'currentsApi', + { + method: 'GET', + url: 'https://api.currents.dev/v1/webhooks', + qs: { projectId: 'project-123' }, + }, + ); + expect(result).toEqual(mockWebhooks); + }); + + it('should return empty array when no webhooks exist', async () => { + (mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({ + data: null, + }); + + const result = await listWebhooks.call(mockHookFunctions as IHookFunctions, 'project-123'); + + expect(result).toEqual([]); + }); + }); + + describe('findWebhookByUrl', () => { + let mockHookFunctions: Partial; + + beforeEach(() => { + mockHookFunctions = { + helpers: { + httpRequestWithAuthentication: jest.fn(), + } as unknown as IHookFunctions['helpers'], + }; + }); + + it('should find webhook matching URL', async () => { + const targetUrl = 'https://example.com/webhook2'; + const mockWebhooks = [ + { + hookId: 'hook-1', + projectId: 'project-123', + url: 'https://example.com/webhook1', + hookEvents: ['RUN_FINISH'], + }, + { + hookId: 'hook-2', + projectId: 'project-123', + url: targetUrl, + hookEvents: ['RUN_START'], + }, + ]; + + (mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({ + data: mockWebhooks, + }); + + const result = await findWebhookByUrl.call( + mockHookFunctions as IHookFunctions, + 'project-123', + targetUrl, + ); + + expect(result).toEqual(mockWebhooks[1]); + }); + + it('should return undefined when no webhook matches URL', async () => { + const mockWebhooks = [ + { + hookId: 'hook-1', + projectId: 'project-123', + url: 'https://example.com/webhook1', + hookEvents: ['RUN_FINISH'], + }, + ]; + + (mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({ + data: mockWebhooks, + }); + + const result = await findWebhookByUrl.call( + mockHookFunctions as IHookFunctions, + 'project-123', + 'https://example.com/nonexistent', + ); + + expect(result).toBeUndefined(); + }); + }); + + describe('createWebhook', () => { + let mockHookFunctions: Partial; + + beforeEach(() => { + mockHookFunctions = { + helpers: { + httpRequestWithAuthentication: jest.fn(), + } as unknown as IHookFunctions['helpers'], + }; + }); + + it('should create webhook with all options', async () => { + const createdWebhook = { + hookId: 'new-hook-id', + projectId: 'project-123', + url: 'https://example.com/webhook', + hookEvents: ['RUN_FINISH', 'RUN_START'], + headers: '{"x-webhook-secret":"secret123"}', + label: 'n8n workflow 456', + }; + + (mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({ + data: createdWebhook, + }); + + const result = await createWebhook.call(mockHookFunctions as IHookFunctions, 'project-123', { + url: 'https://example.com/webhook', + hookEvents: ['RUN_FINISH', 'RUN_START'], + headers: '{"x-webhook-secret":"secret123"}', + label: 'n8n workflow 456', + }); + + expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'currentsApi', + { + method: 'POST', + url: 'https://api.currents.dev/v1/webhooks', + qs: { projectId: 'project-123' }, + body: { + url: 'https://example.com/webhook', + hookEvents: ['RUN_FINISH', 'RUN_START'], + headers: '{"x-webhook-secret":"secret123"}', + label: 'n8n workflow 456', + }, + }, + ); + expect(result).toEqual(createdWebhook); + }); + + it('should create webhook with minimal options', async () => { + const createdWebhook = { + hookId: 'new-hook-id', + projectId: 'project-123', + url: 'https://example.com/webhook', + hookEvents: [], + }; + + (mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({ + data: createdWebhook, + }); + + const result = await createWebhook.call(mockHookFunctions as IHookFunctions, 'project-123', { + url: 'https://example.com/webhook', + }); + + expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'currentsApi', + { + method: 'POST', + url: 'https://api.currents.dev/v1/webhooks', + qs: { projectId: 'project-123' }, + body: { + url: 'https://example.com/webhook', + hookEvents: [], + headers: undefined, + label: undefined, + }, + }, + ); + expect(result).toEqual(createdWebhook); + }); + }); + + describe('updateWebhook', () => { + let mockHookFunctions: Partial; + + beforeEach(() => { + mockHookFunctions = { + helpers: { + httpRequestWithAuthentication: jest.fn(), + } as unknown as IHookFunctions['helpers'], + }; + }); + + it('should update webhook with new events', async () => { + const updatedWebhook = { + hookId: 'hook-123', + projectId: 'project-123', + url: 'https://example.com/webhook', + hookEvents: ['RUN_FINISH', 'RUN_TIMEOUT'], + }; + + (mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({ + data: updatedWebhook, + }); + + const result = await updateWebhook.call(mockHookFunctions as IHookFunctions, 'hook-123', { + hookEvents: ['RUN_FINISH', 'RUN_TIMEOUT'], + }); + + expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'currentsApi', + { + method: 'PUT', + url: 'https://api.currents.dev/v1/webhooks/hook-123', + body: { + hookEvents: ['RUN_FINISH', 'RUN_TIMEOUT'], + }, + }, + ); + expect(result).toEqual(updatedWebhook); + }); + + it('should update webhook with multiple fields', async () => { + const updatedWebhook = { + hookId: 'hook-123', + projectId: 'project-123', + url: 'https://example.com/new-webhook', + hookEvents: ['RUN_START'], + headers: '{"x-webhook-secret":"newsecret"}', + label: 'updated label', + }; + + (mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({ + data: updatedWebhook, + }); + + const result = await updateWebhook.call(mockHookFunctions as IHookFunctions, 'hook-123', { + url: 'https://example.com/new-webhook', + hookEvents: ['RUN_START'], + headers: '{"x-webhook-secret":"newsecret"}', + label: 'updated label', + }); + + expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'currentsApi', + { + method: 'PUT', + url: 'https://api.currents.dev/v1/webhooks/hook-123', + body: { + url: 'https://example.com/new-webhook', + hookEvents: ['RUN_START'], + headers: '{"x-webhook-secret":"newsecret"}', + label: 'updated label', + }, + }, + ); + expect(result).toEqual(updatedWebhook); + }); + }); + + describe('deleteWebhook', () => { + let mockHookFunctions: Partial; + + beforeEach(() => { + mockHookFunctions = { + helpers: { + httpRequestWithAuthentication: jest.fn(), + } as unknown as IHookFunctions['helpers'], + }; + }); + + it('should delete webhook by hookId', async () => { + (mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({}); + + await deleteWebhook.call(mockHookFunctions as IHookFunctions, 'hook-123'); + + expect(mockHookFunctions.helpers!.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'currentsApi', + { + method: 'DELETE', + url: 'https://api.currents.dev/v1/webhooks/hook-123', + }, + ); + }); + + it('should not throw on successful deletion', async () => { + (mockHookFunctions.helpers!.httpRequestWithAuthentication as jest.Mock).mockResolvedValue({}); + + await expect( + deleteWebhook.call(mockHookFunctions as IHookFunctions, 'hook-123'), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Currents/test/listSearch.test.ts b/packages/nodes-base/nodes/Currents/test/listSearch.test.ts new file mode 100644 index 00000000000..1c467f05e1a --- /dev/null +++ b/packages/nodes-base/nodes/Currents/test/listSearch.test.ts @@ -0,0 +1,116 @@ +import type { IDataObject, ILoadOptionsFunctions } from 'n8n-workflow'; + +import { getProjects } from '../methods/listSearch'; + +describe('Currents listSearch', () => { + describe('getProjects', () => { + let mockContext: Partial; + let mockHttpRequest: jest.Mock; + + beforeEach(() => { + mockHttpRequest = jest.fn(); + mockContext = { + helpers: { + httpRequestWithAuthentication: mockHttpRequest, + } as unknown as ILoadOptionsFunctions['helpers'], + }; + }); + + it('should return projects sorted by name', async () => { + const mockProjects: IDataObject[] = [ + { projectId: 'proj2', name: 'Zebra Project' }, + { projectId: 'proj1', name: 'Alpha Project' }, + { projectId: 'proj3', name: 'Beta Project' }, + ]; + + mockHttpRequest.mockResolvedValue({ data: mockProjects }); + + const result = await getProjects.call(mockContext as ILoadOptionsFunctions); + + expect(result.results).toEqual([ + { name: 'Alpha Project', value: 'proj1' }, + { name: 'Beta Project', value: 'proj3' }, + { name: 'Zebra Project', value: 'proj2' }, + ]); + }); + + it('should filter projects by name (case-insensitive)', async () => { + const mockProjects: IDataObject[] = [ + { projectId: 'proj1', name: 'Test Project' }, + { projectId: 'proj2', name: 'Production' }, + { projectId: 'proj3', name: 'Testing Environment' }, + ]; + + mockHttpRequest.mockResolvedValue({ data: mockProjects }); + + const result = await getProjects.call(mockContext as ILoadOptionsFunctions, 'test'); + + expect(result.results).toEqual([ + { name: 'Test Project', value: 'proj1' }, + { name: 'Testing Environment', value: 'proj3' }, + ]); + }); + + it('should filter projects by projectId (case-insensitive)', async () => { + const mockProjects: IDataObject[] = [ + { projectId: 'ABC123', name: 'Project A' }, + { projectId: 'DEF456', name: 'Project B' }, + { projectId: 'abc789', name: 'Project C' }, + ]; + + mockHttpRequest.mockResolvedValue({ data: mockProjects }); + + const result = await getProjects.call(mockContext as ILoadOptionsFunctions, 'abc'); + + expect(result.results).toEqual([ + { name: 'Project A', value: 'ABC123' }, + { name: 'Project C', value: 'abc789' }, + ]); + }); + + it('should handle empty project list', async () => { + mockHttpRequest.mockResolvedValue({ data: [] }); + + const result = await getProjects.call(mockContext as ILoadOptionsFunctions); + + expect(result.results).toEqual([]); + }); + + it('should handle missing data property', async () => { + mockHttpRequest.mockResolvedValue({}); + + const result = await getProjects.call(mockContext as ILoadOptionsFunctions); + + expect(result.results).toEqual([]); + }); + + it('should call API with correct parameters', async () => { + mockHttpRequest.mockResolvedValue({ data: [] }); + + await getProjects.call(mockContext as ILoadOptionsFunctions); + + expect(mockHttpRequest).toHaveBeenCalledWith('currentsApi', { + method: 'GET', + url: 'https://api.currents.dev/v1/projects', + }); + }); + + it('should handle projects with missing name gracefully', async () => { + const mockProjects: IDataObject[] = [ + { projectId: 'proj1', name: 'Valid Project' }, + { projectId: 'proj2' }, // missing name - should use projectId as fallback + ]; + + mockHttpRequest.mockResolvedValue({ data: mockProjects }); + + const result = await getProjects.call(mockContext as ILoadOptionsFunctions); + + // Should use projectId as name fallback when name is missing + expect(result.results).toEqual([ + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + { name: 'proj2', value: 'proj2' }, // projectId used as name + { name: 'Valid Project', value: 'proj1' }, + ]); + }); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e6bf8631960..c7d8cff2e8b 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -81,6 +81,7 @@ "dist/credentials/CortexApi.credentials.js", "dist/credentials/CrateDb.credentials.js", "dist/credentials/CrowdStrikeOAuth2Api.credentials.js", + "dist/credentials/CurrentsApi.credentials.js", "dist/credentials/CustomerIoApi.credentials.js", "dist/credentials/DatadogApi.credentials.js", "dist/credentials/DeepLApi.credentials.js", @@ -483,6 +484,8 @@ "dist/nodes/CrateDb/CrateDb.node.js", "dist/nodes/Cron/Cron.node.js", "dist/nodes/Crypto/Crypto.node.js", + "dist/nodes/Currents/Currents.node.js", + "dist/nodes/Currents/CurrentsTrigger.node.js", "dist/nodes/CustomerIo/CustomerIo.node.js", "dist/nodes/CustomerIo/CustomerIoTrigger.node.js", "dist/nodes/DataTable/DataTable.node.js", diff --git a/packages/testing/containers/n8n-start-stack.ts b/packages/testing/containers/n8n-start-stack.ts index 082956c2e2f..75da347de1f 100644 --- a/packages/testing/containers/n8n-start-stack.ts +++ b/packages/testing/containers/n8n-start-stack.ts @@ -7,6 +7,7 @@ import { BASE_PERFORMANCE_PLANS, isValidPerformancePlan } from './performance-pl import type { CloudflaredResult } from './services/cloudflared'; import type { KeycloakResult } from './services/keycloak'; import type { MailpitResult } from './services/mailpit'; +import type { NgrokResult } from './services/ngrok'; import type { TracingResult } from './services/tracing'; import type { ServiceName } from './services/types'; import type { VictoriaLogsResult } from './services/victoria-logs'; @@ -51,6 +52,7 @@ ${colors.yellow}Options:${colors.reset} --tracing Enable tracing stack (n8n-tracer + Jaeger) for workflow visualization --kafka Enable Kafka broker for message queue trigger testing --tunnel Enable Cloudflare Tunnel for public URL (via trycloudflare.com) + --ngrok Enable ngrok tunnel for public URL (requires NGROK_AUTHTOKEN env var) --mailpit Enable Mailpit for email testing --mains Number of main instances (default: 1) --workers Number of worker instances (default: 1) @@ -133,6 +135,7 @@ async function main() { tracing: { type: 'boolean' }, kafka: { type: 'boolean' }, tunnel: { type: 'boolean' }, + ngrok: { type: 'boolean' }, mailpit: { type: 'boolean' }, mains: { type: 'string' }, workers: { type: 'string' }, @@ -157,6 +160,7 @@ async function main() { if (values.tracing) services.push('tracing'); if (values.kafka) services.push('kafka'); if (values.tunnel) services.push('cloudflared'); + if (values.ngrok) services.push('ngrok'); if (values.mailpit) services.push('mailpit'); // Build configuration @@ -293,6 +297,14 @@ async function main() { log.info('Webhooks are accessible from the internet via this URL'); } + const ngrokResult = stack.serviceResults.ngrok as NgrokResult | undefined; + if (ngrokResult) { + console.log(''); + log.header('ngrok Tunnel'); + log.info(`Public URL: ${colors.cyan}${ngrokResult.meta.publicUrl}${colors.reset}`); + log.info('Webhooks are accessible from the internet via this URL'); + } + const mailpitResult = stack.serviceResults.mailpit as MailpitResult | undefined; if (mailpitResult) { console.log(''); @@ -360,6 +372,8 @@ function displayConfig(config: N8NConfig) { // Display tunnel status if (services.includes('cloudflared')) { log.info('Tunnel: enabled (Cloudflare Quick Tunnel)'); + } else if (services.includes('ngrok')) { + log.info('Tunnel: enabled (ngrok)'); } else { log.info('Tunnel: disabled'); } diff --git a/packages/testing/containers/services/ngrok.ts b/packages/testing/containers/services/ngrok.ts new file mode 100644 index 00000000000..7f32cde4a22 --- /dev/null +++ b/packages/testing/containers/services/ngrok.ts @@ -0,0 +1,97 @@ +import { GenericContainer, Wait } from 'testcontainers'; + +import { createSilentLogConsumer } from '../helpers/utils'; +import { TEST_CONTAINER_IMAGES } from '../test-containers'; +import type { Service, ServiceResult, StartContext } from './types'; + +export interface NgrokMeta { + publicUrl: string; + proxyHops: number; +} + +export type NgrokResult = ServiceResult; + +const API_PORT = 4040; + +function getTunnelTarget(ctx: StartContext): string { + if (ctx.needsLoadBalancer) { + return `${ctx.projectName}-caddy-lb:80`; + } + return `${ctx.projectName}-n8n:5678`; +} + +export const ngrok: Service = { + description: 'ngrok Tunnel', + dependsOn: ['loadBalancer'], + + shouldStart: (ctx) => ctx.config.services?.includes('ngrok') ?? false, + + getOptions(ctx) { + const proxyHops = ctx.needsLoadBalancer ? 2 : 1; + return { tunnelTarget: getTunnelTarget(ctx), proxyHops }; + }, + + env(result) { + return { + WEBHOOK_URL: result.meta.publicUrl, + N8N_PROXY_HOPS: String(result.meta.proxyHops), + }; + }, + + async start(network, projectName, config?: unknown): Promise { + const { tunnelTarget, proxyHops } = config as { tunnelTarget: string; proxyHops: number }; + const { consumer, throwWithLogs } = createSilentLogConsumer(); + + const authToken = process.env.NGROK_AUTHTOKEN; + if (!authToken) { + throw new Error( + 'NGROK_AUTHTOKEN environment variable is required. ' + + 'Get a free token at https://dashboard.ngrok.com/get-started/your-authtoken', + ); + } + + try { + const container = await new GenericContainer(TEST_CONTAINER_IMAGES.ngrok) + .withNetwork(network) + .withNetworkAliases('ngrok') + .withName(`${projectName}-ngrok`) + .withExposedPorts(API_PORT) + .withEnvironment({ + NGROK_AUTHTOKEN: authToken, + }) + .withCommand(['http', `http://${tunnelTarget}`, '--log', 'stdout']) + .withWaitStrategy(Wait.forLogMessage(/started tunnel/i)) + .withLabels({ + 'com.docker.compose.project': projectName, + 'com.docker.compose.service': 'ngrok', + }) + .withReuse() + .withLogConsumer(consumer) + .start(); + + const hostPort = container.getMappedPort(API_PORT); + const host = container.getHost(); + + // ngrok API returns tunnel info at /api/tunnels + const response = await fetch(`http://${host}:${hostPort}/api/tunnels`); + const data = (await response.json()) as { + tunnels: Array<{ public_url: string; proto: string }>; + }; + + // Find the https tunnel + const httpsTunnel = data.tunnels.find((t) => t.proto === 'https'); + const publicUrl = httpsTunnel?.public_url ?? data.tunnels[0]?.public_url; + + if (!publicUrl) { + throw new Error('Failed to get ngrok public URL from API'); + } + + return { + container, + meta: { publicUrl, proxyHops }, + }; + } catch (error) { + return throwWithLogs(error); + } + }, +}; diff --git a/packages/testing/containers/services/registry.ts b/packages/testing/containers/services/registry.ts index 318be825a3a..b3f058cf11c 100644 --- a/packages/testing/containers/services/registry.ts +++ b/packages/testing/containers/services/registry.ts @@ -4,6 +4,7 @@ import { kafka, createKafkaHelper } from './kafka'; import { keycloak, createKeycloakHelper } from './keycloak'; import { loadBalancer } from './load-balancer'; import { mailpit, createMailpitHelper } from './mailpit'; +import { ngrok } from './ngrok'; import { createObservabilityHelper } from './observability'; import { postgres } from './postgres'; import { proxy } from './proxy'; @@ -30,6 +31,7 @@ export const services: Record> = { taskRunner, loadBalancer, cloudflared, + ngrok, kafka, }; diff --git a/packages/testing/containers/services/types.ts b/packages/testing/containers/services/types.ts index 732ea05e0a8..ceab5e05a06 100644 --- a/packages/testing/containers/services/types.ts +++ b/packages/testing/containers/services/types.ts @@ -15,6 +15,7 @@ export const SERVICE_NAMES = [ 'loadBalancer', 'cloudflared', 'kafka', + 'ngrok', ] as const; export type ServiceName = (typeof SERVICE_NAMES)[number]; diff --git a/packages/testing/containers/test-containers.ts b/packages/testing/containers/test-containers.ts index 41514a9c53c..236eed5181b 100644 --- a/packages/testing/containers/test-containers.ts +++ b/packages/testing/containers/test-containers.ts @@ -35,6 +35,7 @@ export const TEST_CONTAINER_IMAGES = { n8nTracer: 'ghcr.io/ivov/n8n-tracer:0.1.0', jaeger: 'jaegertracing/all-in-one:1.76.0', cloudflared: 'cloudflare/cloudflared:2025.1.1', + ngrok: 'ngrok/ngrok:alpine', // Kafka for message queue testing kafka: 'confluentinc/cp-kafka:8.0.3', } as const;