first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user