first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,131 @@
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { actionFields, actionOperations } from './descriptions/ActionDescription';
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';
import { listSearch } from './methods';
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',
},
arrayFormat: 'brackets',
},
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,
};
}
@@ -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<boolean> {
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<boolean> {
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<boolean> {
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<IWebhookResponseData> {
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])],
};
}
}
@@ -0,0 +1,158 @@
import { randomBytes } from 'crypto';
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
const CURRENTS_API_BASE = 'https://api.currents.dev/v1';
/**
* 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<CurrentsWebhook[]> {
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<CurrentsWebhook | undefined> {
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<CurrentsWebhook> {
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<CreateWebhookOptions>,
): Promise<CurrentsWebhook> {
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<void> {
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();
const webhookData = this.getWorkflowStaticData('node');
const expectedSecret = webhookData.webhookSecret;
return verifySignatureGeneric({
getExpectedSignature: () => (typeof expectedSecret === 'string' ? expectedSecret : null),
skipIfNoExpectedSignature: true,
getActualSignature: () => {
const actualSecret = headerData[WEBHOOK_SECRET_HEADER];
return typeof actualSecret === 'string' ? actualSecret : null;
},
getTimestamp: () => {
const timestampHeader = req.headers['x-timestamp'];
return typeof timestampHeader === 'string' ? timestampHeader : null;
},
skipIfNoTimestamp: true,
});
}
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none" version="1.2" baseProfile="tiny-ps"><title>logo-bimi.svg</title><path d="M4.28858 16.0003C-4.54323 31.296 0.704656 50.8796 16.0003 59.7114C31.296 68.5432 50.8796 63.2953 59.7114 47.9997C68.5432 32.704 63.2953 13.1204 47.9997 4.28858C32.704 -4.54323 13.1204 0.704661 4.28858 16.0003ZM11.8404 20.3522C18.2403 9.21648 32.512 5.44056 43.6478 11.8404C54.7835 18.2403 58.5594 32.512 52.1596 43.6478C45.7597 54.7835 31.488 58.5594 20.3522 52.1596C9.21648 45.7597 5.44056 31.488 11.8404 20.3522Z" fill="url(#paint0_linear_406_1455)"></path><path d="M32.1192 49.4022C22.576 49.4022 14.8397 41.666 14.8397 32.1228C14.8397 22.5796 22.576 14.8433 32.1192 14.8433C41.6624 14.8433 49.3987 22.5796 49.3987 32.1228C49.3987 41.666 41.6624 49.4022 32.1192 49.4022Z" fill="url(#paint1_linear_406_1455)"></path><defs><linearGradient id="paint0_linear_406_1455" x1="59.7128" y1="16" x2="4.28724" y2="48" gradientUnits="userSpaceOnUse"><stop stop-color="#358A85"></stop><stop offset="1" stop-color="#6BEDAD"></stop></linearGradient><linearGradient id="paint1_linear_406_1455" x1="17.1547" y1="40.7625" x2="47.0836" y2="23.483" gradientUnits="userSpaceOnUse"><stop stop-color="#358A85"></stop><stop offset="1" stop-color="#6BEDAD"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,459 @@
import type { INodeProperties } from 'n8n-workflow';
import { projectRLC } from './common.descriptions';
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
// ----------------------------------
{
...projectRLC,
displayOptions: {
show: {
resource: ['action'],
operation: ['getAll'],
},
},
routing: {
send: {
type: 'query',
property: 'projectId',
value: '={{ $value }}',
},
},
},
// ----------------------------------
// action:create
// ----------------------------------
{
...projectRLC,
displayOptions: {
show: {
resource: ['action'],
operation: ['create'],
},
},
routing: {
send: {
type: 'query',
property: 'projectId',
value: '={{ $value }}',
},
},
},
// ----------------------------------
// 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: 'Archived', value: 'archived' },
{ name: 'Disabled', value: 'disabled' },
{ name: 'Expired', value: 'expired' },
],
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',
value: '={{ [{ "op": $value }] }}',
},
},
},
{
displayName: 'Tags',
name: 'actionTags',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['action'],
operation: ['create'],
actionType: ['tag'],
},
},
routing: {
send: {
type: 'body',
property: 'action',
value:
'={{ [{ "op": "tag", "details": { "tags": $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'],
},
},
// No routing - matcherValue will build the complete matcher object
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:
'={{ { "op": "AND", "cond": [{ "type": { "signature": "testId", "titleContains": "title", "titleEquals": "title", "specContains": "file", "specEquals": "file" }[$parameter.matcherType] || "title", "op": { "signature": "eq", "titleContains": "inc", "titleEquals": "eq", "specContains": "inc", "specEquals": "eq" }[$parameter.matcherType] || "inc", "value": $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)',
},
],
},
];
@@ -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',
},
];
@@ -0,0 +1,205 @@
import type { INodeProperties } from 'n8n-workflow';
import {
filterAuthorsOption,
filterBranchesOption,
filterGroupsOption,
filterTagsOption,
projectRLC,
} from './common.descriptions';
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
// ----------------------------------
{
...projectRLC,
displayOptions: {
show: {
resource: ['project'],
operation: ['get', 'getInsights'],
},
},
},
// ----------------------------------
// 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: [
filterAuthorsOption,
filterBranchesOption,
filterGroupsOption,
{
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',
},
filterTagsOption,
],
},
];
@@ -0,0 +1,549 @@
import type { INodeProperties } from 'n8n-workflow';
import {
filterAuthorsOption,
filterBranchesOption,
filterTagsOption,
projectRLC,
} from './common.descriptions';
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
// ----------------------------------
{
...projectRLC,
displayOptions: {
show: {
resource: ['run'],
operation: ['find', 'getAll'],
},
},
routing: {
send: {
type: 'query',
property: 'projectId',
value: '={{ $value }}',
},
},
},
{
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',
},
filterTagsOption,
],
},
// ----------------------------------
// 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: [
filterAuthorsOption,
filterBranchesOption,
{
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',
},
filterTagsOption,
{
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)',
},
],
},
];
@@ -0,0 +1,107 @@
import type { INodeProperties } from 'n8n-workflow';
import { projectRLC } from './common.descriptions';
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
// ----------------------------------
{
...projectRLC,
displayOptions: {
show: {
resource: ['signature'],
operation: ['generate'],
},
},
routing: {
send: {
type: 'body',
property: 'projectId',
value: '={{ $value }}',
},
},
},
{
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").',
},
];
@@ -0,0 +1,244 @@
import type { INodeProperties } from 'n8n-workflow';
import {
filterAuthorsOption,
filterBranchesOption,
filterGroupsOption,
filterTagsOption,
projectRLC,
} from './common.descriptions';
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
// ----------------------------------
{
...projectRLC,
displayOptions: {
show: {
resource: ['specFile'],
operation: ['getAll'],
},
},
},
{
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: [
filterAuthorsOption,
filterBranchesOption,
filterGroupsOption,
{
displayName: 'Spec Name',
name: 'specNameFilter',
type: 'string',
default: '',
routing: {
send: {
type: 'query',
property: 'specNameFilter',
},
},
description: 'Filter spec files by name (partial match)',
},
filterTagsOption,
],
},
{
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)',
},
],
},
];
@@ -0,0 +1,296 @@
import type { INodeProperties } from 'n8n-workflow';
import {
filterAuthorsOption,
filterBranchesOption,
filterGroupsOption,
filterTagsOption,
projectRLC,
} from './common.descriptions';
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
// ----------------------------------
{
...projectRLC,
displayOptions: {
show: {
resource: ['test'],
operation: ['getAll'],
},
},
},
{
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: [
filterAuthorsOption,
filterBranchesOption,
filterGroupsOption,
{
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)',
},
filterTagsOption,
{
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 Delta', value: 'durationDelta' },
{ name: 'Duration Impact', value: 'durationXSamples' },
{ name: 'Executions', value: 'executions' },
{ name: 'Failure Impact', value: 'failRateXSamples' },
{ name: 'Failure Rate Delta', value: 'failureRateDelta' },
{ name: 'Failures', value: 'failures' },
{ name: 'Flakiness', value: 'flakiness' },
{ name: 'Flakiness Impact', value: 'flakinessXSamples' },
{ name: 'Flakiness Rate Delta', value: 'flakinessRateDelta' },
{ 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)',
},
{
displayName: 'Metric Settings',
name: 'metric_settings',
type: 'string',
default: '',
routing: {
send: {
type: 'query',
property: 'metric_settings',
},
},
description:
'Override which test statuses are included in metric calculations. JSON object with optional keys: executions, avgDuration, flakinessRate, failureRate. Each value is an array of status strings: passed, failed, pending, skipped. Example: {"executions":["failed","passed"],"failureRate":["failed"]}',
placeholder: '{"executions":["failed","passed"],"failureRate":["failed"]}',
},
],
},
];
@@ -0,0 +1,213 @@
import type { INodeProperties } from 'n8n-workflow';
import {
filterAuthorsOption,
filterBranchesOption,
filterGroupsOption,
filterTagsOption,
} from './common.descriptions';
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: [
filterBranchesOption,
filterAuthorsOption,
filterGroupsOption,
{
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',
},
{
...filterTagsOption,
description: 'Filter by run tags (multiple values supported)',
},
],
},
{
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',
},
],
},
];
@@ -0,0 +1,92 @@
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',
},
],
};
/** Expression: comma-separated string → array for brackets[] query serialization */
const COMMA_TO_ARRAY_VALUE =
"={{ $value && String($value).trim() ? String($value).split(',').map(v => v.trim()).filter(Boolean) : undefined }}";
export const filterAuthorsOption: INodeProperties = {
displayName: 'Git Authors',
name: 'authors',
type: 'string',
default: '',
routing: {
send: {
type: 'query',
property: 'authors',
value: COMMA_TO_ARRAY_VALUE,
},
},
description: 'Filter by git authors (comma-separated for multiple)',
};
export const filterBranchesOption: INodeProperties = {
displayName: 'Branches',
name: 'branches',
type: 'string',
default: '',
routing: {
send: {
type: 'query',
property: 'branches',
value: COMMA_TO_ARRAY_VALUE,
},
},
description: 'Filter by branches (comma-separated for multiple)',
};
export const filterGroupsOption: INodeProperties = {
displayName: 'Groups',
name: 'groups',
type: 'string',
default: '',
routing: {
send: {
type: 'query',
property: 'groups',
value: COMMA_TO_ARRAY_VALUE,
},
},
description: 'Filter by groups (comma-separated for multiple)',
};
export const filterTagsOption: INodeProperties = {
displayName: 'Tags',
name: 'tags',
type: 'string',
default: '',
routing: {
send: {
type: 'query',
property: 'tags',
value: COMMA_TO_ARRAY_VALUE,
},
},
description: 'Filter by tags (comma-separated for multiple)',
};
@@ -0,0 +1,5 @@
import { getProjects } from './listSearch';
export const listSearch = {
getProjects,
};
@@ -0,0 +1,33 @@
import type {
IDataObject,
ILoadOptionsFunctions,
INodeListSearchItems,
INodeListSearchResult,
} from 'n8n-workflow';
export async function getProjects(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
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 };
}
@@ -0,0 +1,101 @@
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');
});
it('should use brackets array format for query params (tags[], authors[], etc.)', () => {
const node = new Currents();
const defaults = node.description.requestDefaults as { arrayFormat?: string } | undefined;
expect(defaults).toBeDefined();
expect(defaults?.arrayFormat).toBe('brackets');
});
});
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');
});
});
});
@@ -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<IWebhookFunctions>;
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');
});
});
});
@@ -0,0 +1,469 @@
import type { IDataObject, IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
import {
createWebhook,
deleteWebhook,
findWebhookByUrl,
generateWebhookSecret,
listWebhooks,
updateWebhook,
verifyWebhook,
} from '../CurrentsTriggerHelpers';
describe('CurrentsTriggerHelpers', () => {
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<IWebhookFunctions>;
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<IHookFunctions>;
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<IHookFunctions>;
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<IHookFunctions>;
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<IHookFunctions>;
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<IHookFunctions>;
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();
});
});
});
@@ -0,0 +1,116 @@
import type { IDataObject, ILoadOptionsFunctions } from 'n8n-workflow';
import { getProjects } from '../methods/listSearch';
describe('Currents listSearch', () => {
describe('getProjects', () => {
let mockContext: Partial<ILoadOptionsFunctions>;
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' },
]);
});
});
});