feat(Airtop Node): Add Airtop node (#13809)

This commit is contained in:
Cesar Sanchez
2025-04-01 21:51:04 +02:00
committed by GitHub
parent cf37ee3ced
commit a7a165dda2
48 changed files with 5026 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import type {
IAuthenticateGeneric,
ICredentialType,
ICredentialTestRequest,
INodeProperties,
} from 'n8n-workflow';
export class AirtopApi implements ICredentialType {
name = 'airtopApi';
displayName = 'Airtop API';
documentationUrl = 'airtop';
properties: INodeProperties[] = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
default: '',
description:
'The Airtop API key. You can create one at <a href="https://portal.airtop.ai/api-keys" target="_blank">Airtop</a> for free.',
required: true,
typeOptions: {
password: true,
},
noDataExpression: true,
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.apiKey}}',
'api-key': '={{$credentials.apiKey}}',
},
},
};
test: ICredentialTestRequest = {
request: {
method: 'GET',
baseURL: 'https://api.airtop.ai/api/v1',
url: '/sessions',
qs: {
limit: 10,
},
},
};
}

View File

@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.airtop",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Productivity", "Development"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/airtop/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.airtop/"
}
]
}
}

View File

@@ -0,0 +1,67 @@
import { NodeConnectionTypes } from 'n8n-workflow';
import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-workflow';
import * as extraction from './actions/extraction/Extraction.resource';
import * as interaction from './actions/interaction/Interaction.resource';
import { router } from './actions/router';
import * as session from './actions/session/Session.resource';
import * as window from './actions/window/Window.resource';
export class Airtop implements INodeType {
description: INodeTypeDescription = {
displayName: 'Airtop',
name: 'airtop',
icon: 'file:airtop.svg',
group: ['transform'],
defaultVersion: 1,
version: [1],
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
description: 'Scrape and control any site with Airtop',
usableAsTool: true,
defaults: {
name: 'Airtop',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'airtopApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Session',
value: 'session',
},
{
name: 'Window',
value: 'window',
},
{
name: 'Extraction',
value: 'extraction',
},
{
name: 'Interaction',
value: 'interaction',
},
],
default: 'session',
},
...session.description,
...window.description,
...extraction.description,
...interaction.description,
],
};
async execute(this: IExecuteFunctions) {
return await router.call(this);
}
}

View File

@@ -0,0 +1,318 @@
import { NodeApiError, type IExecuteFunctions, type INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { SESSION_MODE } from './actions/common/fields';
import {
ERROR_MESSAGES,
DEFAULT_TIMEOUT_MINUTES,
MIN_TIMEOUT_MINUTES,
MAX_TIMEOUT_MINUTES,
INTEGRATION_URL,
} from './constants';
import { apiRequest } from './transport';
import type { IAirtopResponse } from './transport/types';
/**
* Validate a required string field
* @param this - The execution context
* @param index - The index of the node
* @param field - The field to validate
* @param fieldName - The name of the field
*/
export function validateRequiredStringField(
this: IExecuteFunctions,
index: number,
field: string,
fieldName: string,
) {
let value = this.getNodeParameter(field, index) as string;
value = (value || '').trim();
const errorMessage = ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', fieldName);
if (!value) {
throw new NodeOperationError(this.getNode(), errorMessage, {
itemIndex: index,
});
}
return value;
}
/**
* Validate the session ID parameter
* @param this - The execution context
* @param index - The index of the node
* @returns The validated session ID
*/
export function validateSessionId(this: IExecuteFunctions, index: number) {
let sessionId = this.getNodeParameter('sessionId', index, '') as string;
sessionId = (sessionId || '').trim();
if (!sessionId) {
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SESSION_ID_REQUIRED, {
itemIndex: index,
});
}
return sessionId;
}
/**
* Validate the session ID and window ID parameters
* @param this - The execution context
* @param index - The index of the node
* @returns The validated session ID and window ID parameters
*/
export function validateSessionAndWindowId(this: IExecuteFunctions, index: number) {
let sessionId = this.getNodeParameter('sessionId', index, '') as string;
let windowId = this.getNodeParameter('windowId', index, '') as string;
sessionId = (sessionId || '').trim();
windowId = (windowId || '').trim();
if (!sessionId) {
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SESSION_ID_REQUIRED, {
itemIndex: index,
});
}
if (!windowId) {
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.WINDOW_ID_REQUIRED, {
itemIndex: index,
});
}
return {
sessionId,
windowId,
};
}
/**
* Validate the profile name parameter
* @param this - The execution context
* @param index - The index of the node
* @returns The validated profile name
*/
export function validateProfileName(this: IExecuteFunctions, index: number) {
let profileName = this.getNodeParameter('profileName', index) as string;
profileName = (profileName || '').trim();
if (!profileName) {
return profileName;
}
if (!/^[a-zA-Z0-9-]+$/.test(profileName)) {
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.PROFILE_NAME_INVALID, {
itemIndex: index,
});
}
return profileName;
}
/**
* Validate the timeout minutes parameter
* @param this - The execution context
* @param index - The index of the node
* @returns The validated timeout minutes
*/
export function validateTimeoutMinutes(this: IExecuteFunctions, index: number) {
const timeoutMinutes = this.getNodeParameter(
'timeoutMinutes',
index,
DEFAULT_TIMEOUT_MINUTES,
) as number;
if (timeoutMinutes < MIN_TIMEOUT_MINUTES || timeoutMinutes > MAX_TIMEOUT_MINUTES) {
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.TIMEOUT_MINUTES_INVALID, {
itemIndex: index,
});
}
return timeoutMinutes;
}
/**
* Validate the URL parameter
* @param this - The execution context
* @param index - The index of the node
* @returns The validated URL
*/
export function validateUrl(this: IExecuteFunctions, index: number) {
let url = this.getNodeParameter('url', index) as string;
url = (url || '').trim();
if (!url) {
return '';
}
if (!url.startsWith('http')) {
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.URL_INVALID, {
itemIndex: index,
});
}
return url;
}
/**
* Validate the Proxy URL parameter
* @param this - The execution context
* @param index - The index of the node
* @param proxy - The value of the Proxy parameter
* @returns The validated proxy URL
*/
export function validateProxyUrl(this: IExecuteFunctions, index: number, proxy: string) {
let proxyUrl = this.getNodeParameter('proxyUrl', index, '') as string;
proxyUrl = (proxyUrl || '').trim();
// only validate proxyUrl if proxy is custom
if (proxy !== 'custom') {
return '';
}
if (!proxyUrl) {
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.PROXY_URL_REQUIRED, {
itemIndex: index,
});
}
if (!proxyUrl.startsWith('http')) {
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.PROXY_URL_INVALID, {
itemIndex: index,
});
}
return proxyUrl;
}
/**
* Validate the screen resolution parameter
* @param this - The execution context
* @param index - The index of the node
* @returns The validated screen resolution
*/
export function validateScreenResolution(this: IExecuteFunctions, index: number) {
let screenResolution = this.getNodeParameter('screenResolution', index, '') as string;
screenResolution = (screenResolution || '').trim().toLowerCase();
const regex = /^\d{3,4}x\d{3,4}$/; // Expected format: 1280x720
if (!screenResolution) {
return '';
}
if (!regex.test(screenResolution)) {
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SCREEN_RESOLUTION_INVALID, {
itemIndex: index,
});
}
return screenResolution;
}
/**
* Validate the save profile on termination parameter
* @param this - The execution context
* @param index - The index of the node
* @param profileName - The profile name
* @returns The validated save profile on termination
*/
export function validateSaveProfileOnTermination(
this: IExecuteFunctions,
index: number,
profileName: string,
) {
const saveProfileOnTermination = this.getNodeParameter(
'saveProfileOnTermination',
index,
false,
) as boolean;
if (saveProfileOnTermination && !profileName) {
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.PROFILE_NAME_REQUIRED, {
itemIndex: index,
});
}
return saveProfileOnTermination;
}
/**
* Check if there is an error in the API response and throw NodeApiError
* @param node - The node instance
* @param response - The response from the API
*/
export function validateAirtopApiResponse(node: INode, response: IAirtopResponse) {
if (response?.errors?.length) {
const errorMessage = response.errors.map((error) => error.message).join('\n');
throw new NodeApiError(node, {
message: errorMessage,
});
}
}
/**
* Convert a screenshot from the API response to a binary buffer
* @param screenshot - The screenshot from the API response
* @returns The processed screenshot
*/
export function convertScreenshotToBinary(screenshot: { dataUrl: string }): Buffer {
const base64Data = screenshot.dataUrl.replace('data:image/jpeg;base64,', '');
const buffer = Buffer.from(base64Data, 'base64');
return buffer;
}
/**
* Check if a new session should be created
* @param this - The execution context
* @param index - The index of the node
* @returns True if a new session should be created, false otherwise
*/
export function shouldCreateNewSession(this: IExecuteFunctions, index: number) {
const sessionMode = this.getNodeParameter('sessionMode', index) as string;
return Boolean(sessionMode && sessionMode === SESSION_MODE.NEW);
}
/**
* Create a new session and window
* @param this - The execution context
* @param index - The index of the node
* @returns The session ID and window ID
*/
export async function createSessionAndWindow(
this: IExecuteFunctions,
index: number,
): Promise<{ sessionId: string; windowId: string }> {
const node = this.getNode();
const noCodeEndpoint = `${INTEGRATION_URL}/create-session`;
const profileName = validateProfileName.call(this, index);
const url = validateRequiredStringField.call(this, index, 'url', 'URL');
const { sessionId } = await apiRequest.call(this, 'POST', noCodeEndpoint, {
configuration: {
profileName,
},
});
if (!sessionId) {
throw new NodeApiError(node, {
message: 'Failed to create session',
code: 500,
});
}
this.logger.info(`[${node.name}] Session successfully created.`);
const windowResponse = await apiRequest.call(this, 'POST', `/sessions/${sessionId}/windows`, {
url,
});
const windowId = windowResponse?.data?.windowId as string;
if (!windowId) {
throw new NodeApiError(node, {
message: 'Failed to create window',
code: 500,
});
}
this.logger.info(`[${node.name}] Window successfully created.`);
return { sessionId, windowId };
}

View File

@@ -0,0 +1,156 @@
import type { INodeProperties } from 'n8n-workflow';
export const SESSION_MODE = {
NEW: 'new',
EXISTING: 'existing',
} as const;
/**
* Session related fields
*/
export const sessionIdField: INodeProperties = {
displayName: 'Session ID',
name: 'sessionId',
type: 'string',
required: true,
default: '={{ $json["sessionId"] }}',
description:
'The ID of the <a href="https://docs.airtop.ai/guides/how-to/creating-a-session" target="_blank">Session</a> to use',
};
export const windowIdField: INodeProperties = {
displayName: 'Window ID',
name: 'windowId',
type: 'string',
required: true,
default: '={{ $json["windowId"] }}',
description:
'The ID of the <a href="https://docs.airtop.ai/guides/how-to/creating-a-session#windows" target="_blank">Window</a> to use',
};
export const profileNameField: INodeProperties = {
displayName: 'Profile Name',
name: 'profileName',
type: 'string',
default: '',
description: 'The name of the Airtop profile to load or create',
hint: '<a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Learn more</a> about Airtop profiles',
placeholder: 'e.g. my-x-profile',
};
export const urlField: INodeProperties = {
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
placeholder: 'e.g. https://google.com',
description: 'URL to load in the window',
};
/**
* Extraction related fields
*/
export const outputSchemaField: INodeProperties = {
displayName: 'JSON Output Schema',
name: 'outputSchema',
description: 'JSON schema defining the structure of the output',
hint: 'If you want to force your output to be JSON, provide a valid JSON schema describing the output. You can generate one automatically in the <a href="https://portal.airtop.ai/" target="_blank">Airtop API Playground</a>.',
type: 'json',
default: '',
};
/**
* Interaction related fields
*/
export const elementDescriptionField: INodeProperties = {
displayName: 'Element Description',
name: 'elementDescription',
type: 'string',
default: '',
description: 'A specific description of the element to execute the interaction on',
placeholder: 'e.g. the search box at the top of the page',
};
export function getSessionModeFields(resource: string, operations: string[]): INodeProperties[] {
return [
{
displayName: 'Session Mode',
name: 'sessionMode',
type: 'options',
default: 'existing',
description: 'Choose between creating a new session or using an existing one',
options: [
{
name: 'Automatically Create Session',
description: 'Automatically create a new session and window for this operation',
value: SESSION_MODE.NEW,
},
{
name: 'Use Existing Session',
description: 'Use an existing session and window for this operation',
value: SESSION_MODE.EXISTING,
},
],
displayOptions: {
show: {
resource: [resource],
operation: operations,
},
},
},
{
...sessionIdField,
displayOptions: {
show: {
resource: [resource],
sessionMode: [SESSION_MODE.EXISTING],
},
},
},
{
...windowIdField,
displayOptions: {
show: {
resource: [resource],
sessionMode: [SESSION_MODE.EXISTING],
},
},
},
{
...urlField,
required: true,
displayOptions: {
show: {
resource: [resource],
sessionMode: [SESSION_MODE.NEW],
},
},
},
{
...profileNameField,
displayOptions: {
show: {
resource: [resource],
sessionMode: [SESSION_MODE.NEW],
},
},
},
{
displayName: 'Auto-Terminate Session',
name: 'autoTerminateSession',
type: 'boolean',
default: true,
description:
'Whether to terminate the session after the operation is complete. When disabled, you must manually terminate the session. By default, idle sessions timeout after 10 minutes',
displayOptions: {
show: {
resource: [resource],
sessionMode: [SESSION_MODE.NEW],
},
},
},
];
}

View File

@@ -0,0 +1,45 @@
import type { INodeExecutionData, IExecuteFunctions, IDataObject } from 'n8n-workflow';
import {
validateSessionAndWindowId,
createSessionAndWindow,
shouldCreateNewSession,
validateAirtopApiResponse,
} from '../../GenericFunctions';
import { apiRequest } from '../../transport';
/**
* Execute the node operation. Creates and terminates a new session if needed.
* @param this - The execution context
* @param index - The index of the node
* @param request - The request to execute
* @returns The response from the request
*/
export async function executeRequestWithSessionManagement(
this: IExecuteFunctions,
index: number,
request: {
method: 'POST' | 'DELETE';
path: string;
body: IDataObject;
},
): Promise<INodeExecutionData[]> {
const { sessionId, windowId } = shouldCreateNewSession.call(this, index)
? await createSessionAndWindow.call(this, index)
: validateSessionAndWindowId.call(this, index);
const shouldTerminateSession = this.getNodeParameter('autoTerminateSession', index, false);
const endpoint = request.path.replace('{sessionId}', sessionId).replace('{windowId}', windowId);
const response = await apiRequest.call(this, request.method, endpoint, request.body);
validateAirtopApiResponse(this.getNode(), response);
if (shouldTerminateSession) {
await apiRequest.call(this, 'DELETE', `/sessions/${sessionId}`);
this.logger.info(`[${this.getNode().name}] Session terminated.`);
return this.helpers.returnJsonArray({ ...response });
}
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
}

View File

@@ -0,0 +1,46 @@
import type { INodeProperties } from 'n8n-workflow';
import * as getPaginated from './getPaginated.operation';
import * as query from './query.operation';
import * as scrape from './scrape.operation';
import { getSessionModeFields } from '../common/fields';
export { getPaginated, query, scrape };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['extraction'],
},
},
options: [
{
name: 'Query Page',
value: 'query',
description: 'Query a page to extract data or ask a question given the data on the page',
action: 'Query page',
},
{
name: 'Query Page with Pagination',
value: 'getPaginated',
description: 'Extract content from paginated or dynamically loaded pages',
action: 'Query page with pagination',
},
{
name: 'Smart Scrape',
value: 'scrape',
description: 'Scrape a page and return the data as markdown',
action: 'Smart scrape page',
},
],
default: 'getPaginated',
},
...getSessionModeFields('extraction', ['getPaginated', 'query', 'scrape']),
...getPaginated.description,
...query.description,
];

View File

@@ -0,0 +1,114 @@
import {
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
} from 'n8n-workflow';
import { outputSchemaField } from '../common/fields';
import { executeRequestWithSessionManagement } from '../common/session.utils';
export const description: INodeProperties[] = [
{
displayName: 'Prompt',
name: 'prompt',
type: 'string',
typeOptions: {
rows: 4,
},
required: true,
default: '',
displayOptions: {
show: {
resource: ['extraction'],
operation: ['getPaginated'],
},
},
description: 'The prompt to extract data from the pages',
placeholder: 'e.g. Extract all the product names and prices',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['extraction'],
operation: ['getPaginated'],
},
},
options: [
{
...outputSchemaField,
},
{
displayName: 'Interaction Mode',
name: 'interactionMode',
type: 'options',
default: 'auto',
description: 'The strategy for interacting with the page',
options: [
{
name: 'Auto',
description: 'Automatically choose the most cost-effective mode',
value: 'auto',
},
{
name: 'Accurate',
description: 'Prioritize accuracy over cost',
value: 'accurate',
},
{
name: 'Cost Efficient',
description: 'Minimize costs while ensuring effectiveness',
value: 'cost-efficient',
},
],
},
{
displayName: 'Pagination Mode',
name: 'paginationMode',
type: 'options',
default: 'auto',
description: 'The pagination approach to use',
options: [
{
name: 'Auto',
description: 'Look for pagination links first, then try infinite scrolling',
value: 'auto',
},
{
name: 'Paginated',
description: 'Only use pagination links',
value: 'paginated',
},
{
name: 'Infinite Scroll',
description: 'Scroll the page to load more content',
value: 'infinite-scroll',
},
],
},
],
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const prompt = this.getNodeParameter('prompt', index, '') as string;
const additionalFields = this.getNodeParameter('additionalFields', index);
return await executeRequestWithSessionManagement.call(this, index, {
method: 'POST',
path: '/sessions/{sessionId}/windows/{windowId}/paginated-extraction',
body: {
prompt,
configuration: {
...additionalFields,
},
},
});
}

View File

@@ -0,0 +1,66 @@
import {
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
} from 'n8n-workflow';
import { outputSchemaField } from '../common/fields';
import { executeRequestWithSessionManagement } from '../common/session.utils';
export const description: INodeProperties[] = [
{
displayName: 'Prompt',
name: 'prompt',
type: 'string',
typeOptions: {
rows: 4,
},
required: true,
default: '',
placeholder: 'e.g. Is there a login form in this page?',
displayOptions: {
show: {
resource: ['extraction'],
operation: ['query'],
},
},
description: 'The prompt to query the page content',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['extraction'],
operation: ['query'],
},
},
options: [
{
...outputSchemaField,
},
],
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const prompt = this.getNodeParameter('prompt', index, '') as string;
const additionalFields = this.getNodeParameter('additionalFields', index);
return await executeRequestWithSessionManagement.call(this, index, {
method: 'POST',
path: '/sessions/{sessionId}/windows/{windowId}/page-query',
body: {
prompt,
configuration: {
...additionalFields,
},
},
});
}

View File

@@ -0,0 +1,14 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { executeRequestWithSessionManagement } from '../common/session.utils';
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
return await executeRequestWithSessionManagement.call(this, index, {
method: 'POST',
path: '/sessions/{sessionId}/windows/{windowId}/scrape-content',
body: {},
});
}

View File

@@ -0,0 +1,131 @@
import type { INodeProperties } from 'n8n-workflow';
import * as click from './click.operation';
import * as hover from './hover.operation';
import * as type from './type.operation';
import { sessionIdField, windowIdField } from '../common/fields';
export { click, hover, type };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['interaction'],
},
},
options: [
{
name: 'Click an Element',
value: 'click',
description: 'Execute a click on an element given a description',
action: 'Click an element',
},
{
name: 'Hover on an Element',
value: 'hover',
description: 'Execute a hover action on an element given a description',
action: 'Hover on an element',
},
{
name: 'Type',
value: 'type',
description: 'Execute a Type action on an element given a description',
action: 'Type text',
},
],
default: 'click',
},
{
...sessionIdField,
displayOptions: {
show: {
resource: ['interaction'],
},
},
},
{
...windowIdField,
displayOptions: {
show: {
resource: ['interaction'],
},
},
},
...click.description,
...hover.description,
...type.description,
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['interaction'],
},
},
options: [
{
displayName: 'Visual Scope',
name: 'visualScope',
type: 'options',
default: 'auto',
description: 'Defines the strategy for visual analysis of the current window',
options: [
{
name: 'Auto',
description: 'Provides the simplest out-of-the-box experience for most web pages',
value: 'auto',
},
{
name: 'Viewport',
description: 'For analysis of the current browser view only',
value: 'viewport',
},
{
name: 'Page',
description: 'For analysis of the entire page',
value: 'page',
},
{
name: 'Scan',
description:
"For a full page analysis on sites that have compatibility issues with 'Page' mode",
value: 'scan',
},
],
},
{
displayName: 'Wait Until Event After Navigation',
name: 'waitForNavigation',
type: 'options',
default: 'load',
description:
"The condition to wait for the navigation to complete after an interaction (click, type or hover). Defaults to 'Fully Loaded'.",
options: [
{
name: 'Fully Loaded (Slower)',
value: 'load',
},
{
name: 'DOM Only Loaded (Faster)',
value: 'domcontentloaded',
},
{
name: 'All Network Activity Has Stopped',
value: 'networkidle0',
},
{
name: 'Most Network Activity Has Stopped',
value: 'networkidle2',
},
],
},
],
},
];

View File

@@ -0,0 +1,56 @@
import {
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
} from 'n8n-workflow';
import { constructInteractionRequest } from './helpers';
import {
validateRequiredStringField,
validateSessionAndWindowId,
validateAirtopApiResponse,
} from '../../GenericFunctions';
import { apiRequest } from '../../transport';
import { elementDescriptionField } from '../common/fields';
export const description: INodeProperties[] = [
{
...elementDescriptionField,
placeholder: 'e.g. the green "save" button at the top of the page',
required: true,
displayOptions: {
show: {
resource: ['interaction'],
operation: ['click'],
},
},
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
const elementDescription = validateRequiredStringField.call(
this,
index,
'elementDescription',
'Element Description',
);
const request = constructInteractionRequest.call(this, index, {
elementDescription,
});
const response = await apiRequest.call(
this,
'POST',
`/sessions/${sessionId}/windows/${windowId}/click`,
request,
);
validateAirtopApiResponse(this.getNode(), response);
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
}

View File

@@ -0,0 +1,31 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import type { IAirtopInteractionRequest } from '../../transport/types';
export function constructInteractionRequest(
this: IExecuteFunctions,
index: number,
parameters: Partial<IAirtopInteractionRequest> = {},
): IAirtopInteractionRequest {
const additionalFields = this.getNodeParameter('additionalFields', index);
const request: IAirtopInteractionRequest = {
configuration: {},
};
if (additionalFields.visualScope) {
request.configuration.visualAnalysis = {
scope: additionalFields.visualScope as string,
};
}
if (additionalFields.waitForNavigation) {
request.waitForNavigation = true;
request.configuration.waitForNavigationConfig = {
waitUntil: additionalFields.waitForNavigation as string,
};
}
Object.assign(request, parameters);
return request;
}

View File

@@ -0,0 +1,56 @@
import {
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
} from 'n8n-workflow';
import { constructInteractionRequest } from './helpers';
import {
validateRequiredStringField,
validateSessionAndWindowId,
validateAirtopApiResponse,
} from '../../GenericFunctions';
import { apiRequest } from '../../transport';
import { elementDescriptionField } from '../common/fields';
export const description: INodeProperties[] = [
{
...elementDescriptionField,
required: true,
placeholder: 'e.g. the rounded user profile image at the top right of the page',
displayOptions: {
show: {
resource: ['interaction'],
operation: ['hover'],
},
},
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
const elementDescription = validateRequiredStringField.call(
this,
index,
'elementDescription',
'Element Description',
);
const request = constructInteractionRequest.call(this, index, {
elementDescription,
});
const response = await apiRequest.call(
this,
'POST',
`/sessions/${sessionId}/windows/${windowId}/hover`,
request,
);
validateAirtopApiResponse(this.getNode(), response);
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
}

View File

@@ -0,0 +1,81 @@
import {
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
} from 'n8n-workflow';
import { constructInteractionRequest } from './helpers';
import {
validateRequiredStringField,
validateSessionAndWindowId,
validateAirtopApiResponse,
} from '../../GenericFunctions';
import { apiRequest } from '../../transport';
import { elementDescriptionField } from '../common/fields';
export const description: INodeProperties[] = [
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
default: '',
displayOptions: {
show: {
resource: ['interaction'],
operation: ['type'],
},
},
description: 'The text to type into the browser window',
placeholder: 'e.g. email@example.com',
},
{
displayName: 'Press Enter Key',
name: 'pressEnterKey',
type: 'boolean',
default: false,
description: 'Whether to press the Enter key after typing the text',
displayOptions: {
show: {
resource: ['interaction'],
operation: ['type'],
},
},
},
{
...elementDescriptionField,
displayOptions: {
show: {
resource: ['interaction'],
operation: ['type'],
},
},
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
const text = validateRequiredStringField.call(this, index, 'text', 'Text');
const pressEnterKey = this.getNodeParameter('pressEnterKey', index) as boolean;
const elementDescription = this.getNodeParameter('elementDescription', index) as string;
const request = constructInteractionRequest.call(this, index, {
text,
pressEnterKey,
elementDescription,
});
const response = await apiRequest.call(
this,
'POST',
`/sessions/${sessionId}/windows/${windowId}/type`,
request,
);
validateAirtopApiResponse(this.getNode(), response);
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
}

View File

@@ -0,0 +1,10 @@
import type { AllEntities } from 'n8n-workflow';
type NodeMap = {
session: 'create' | 'save' | 'terminate';
window: 'create' | 'close' | 'takeScreenshot' | 'load';
extraction: 'getPaginated' | 'query' | 'scrape';
interaction: 'click' | 'hover' | 'type';
};
export type AirtopType = AllEntities<NodeMap>;

View File

@@ -0,0 +1,63 @@
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import * as extraction from './extraction/Extraction.resource';
import * as interaction from './interaction/Interaction.resource';
import type { AirtopType } from './node.type';
import * as session from './session/Session.resource';
import * as window from './window/Window.resource';
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const operationResult: INodeExecutionData[] = [];
let responseData: IDataObject | IDataObject[] = [];
const items = this.getInputData();
const resource = this.getNodeParameter<AirtopType>('resource', 0);
const operation = this.getNodeParameter('operation', 0);
const airtopNodeData = {
resource,
operation,
} as AirtopType;
for (let i = 0; i < items.length; i++) {
try {
switch (airtopNodeData.resource) {
case 'session':
responseData = await session[airtopNodeData.operation].execute.call(this, i);
break;
case 'window':
responseData = await window[airtopNodeData.operation].execute.call(this, i);
break;
case 'interaction':
responseData = await interaction[airtopNodeData.operation].execute.call(this, i);
break;
case 'extraction':
responseData = await extraction[airtopNodeData.operation].execute.call(this, i);
break;
default:
throw new NodeOperationError(
this.getNode(),
`The resource "${resource}" is not supported!`,
);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
operationResult.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
operationResult.push({
json: this.getInputData(i)[0].json,
error: error as NodeOperationError,
});
} else {
throw error;
}
}
}
return [operationResult];
}

View File

@@ -0,0 +1,46 @@
import type { INodeProperties } from 'n8n-workflow';
import * as create from './create.operation';
import * as save from './save.operation';
import * as terminate from './terminate.operation';
export { create, save, terminate };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['session'],
},
},
options: [
{
name: 'Create Session',
value: 'create',
description: 'Create an Airtop browser session',
action: 'Create a session',
},
{
name: 'Save Profile on Termination',
value: 'save',
description:
'Save in a profile changes made in your browsing session such as cookies and local storage',
action: 'Save a profile on session termination',
},
{
name: 'Terminate Session',
value: 'terminate',
description: 'Terminate a session',
action: 'Terminate a session',
},
],
default: 'create',
},
...create.description,
...save.description,
...terminate.description,
];

View File

@@ -0,0 +1,136 @@
import {
type IDataObject,
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
} from 'n8n-workflow';
import { INTEGRATION_URL } from '../../constants';
import {
validateAirtopApiResponse,
validateProfileName,
validateProxyUrl,
validateSaveProfileOnTermination,
validateTimeoutMinutes,
} from '../../GenericFunctions';
import { apiRequest } from '../../transport';
import { profileNameField } from '../common/fields';
export const description: INodeProperties[] = [
{
...profileNameField,
displayOptions: {
show: {
resource: ['session'],
operation: ['create'],
},
},
},
{
displayName: 'Save Profile',
name: 'saveProfileOnTermination',
type: 'boolean',
default: false,
description:
'Whether to automatically save the <a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Airtop profile</a> for this session upon termination',
displayOptions: {
show: {
resource: ['session'],
operation: ['create'],
},
},
},
{
displayName: 'Idle Timeout',
name: 'timeoutMinutes',
type: 'number',
default: 10,
validateType: 'number',
description: 'Minutes to wait before the session is terminated due to inactivity',
displayOptions: {
show: {
resource: ['session'],
operation: ['create'],
},
},
},
{
displayName: 'Proxy',
name: 'proxy',
type: 'options',
default: 'none',
description: 'Choose how to configure the proxy for this session',
options: [
{
name: 'None',
value: 'none',
description: 'No proxy will be used',
},
{
name: 'Integrated',
value: 'integrated',
description: 'Use Airtop-provided proxy',
},
{
name: 'Custom',
value: 'custom',
description: 'Configure a custom proxy',
},
],
displayOptions: {
show: {
resource: ['session'],
operation: ['create'],
},
},
},
{
displayName: 'Proxy URL',
name: 'proxyUrl',
type: 'string',
default: '',
description: 'The URL of the proxy to use',
displayOptions: {
show: {
proxy: ['custom'],
},
},
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const url = `${INTEGRATION_URL}/create-session`;
const profileName = validateProfileName.call(this, index);
const timeoutMinutes = validateTimeoutMinutes.call(this, index);
const saveProfileOnTermination = validateSaveProfileOnTermination.call(this, index, profileName);
const proxyParam = this.getNodeParameter('proxy', index, 'none') as string;
const proxyUrl = validateProxyUrl.call(this, index, proxyParam);
const body: IDataObject = {
configuration: {
profileName,
timeoutMinutes,
proxy: proxyParam === 'custom' ? proxyUrl : Boolean(proxyParam === 'integrated'),
},
};
const response = await apiRequest.call(this, 'POST', url, body);
const sessionId = response.sessionId;
// validate response
validateAirtopApiResponse(this.getNode(), response);
if (saveProfileOnTermination) {
await apiRequest.call(
this,
'PUT',
`/sessions/${sessionId}/save-profile-on-termination/${profileName}`,
);
}
return this.helpers.returnJsonArray({ sessionId } as IDataObject);
}

View File

@@ -0,0 +1,73 @@
import {
type IDataObject,
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
} from 'n8n-workflow';
import {
validateAirtopApiResponse,
validateProfileName,
validateRequiredStringField,
validateSessionId,
} from '../../GenericFunctions';
import { apiRequest } from '../../transport';
import { sessionIdField, profileNameField } from '../common/fields';
export const description: INodeProperties[] = [
{
displayName:
"Note: This operation is not needed if you enabled 'Save Profile' in the 'Create Session' operation",
name: 'notice',
type: 'notice',
displayOptions: {
show: {
resource: ['session'],
operation: ['save'],
},
},
default: 'This operation will save the profile on session termination',
},
{
...sessionIdField,
displayOptions: {
show: {
resource: ['session'],
operation: ['save'],
},
},
},
{
...profileNameField,
required: true,
description:
'The name of the <a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Profile</a> to save',
displayOptions: {
show: {
resource: ['session'],
operation: ['save'],
},
},
hint: 'Name of the profile you want to save. Must consist only of alphanumeric characters and hyphens "-"',
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const sessionId = validateSessionId.call(this, index);
let profileName = validateRequiredStringField.call(this, index, 'profileName', 'Profile Name');
profileName = validateProfileName.call(this, index);
const response = await apiRequest.call(
this,
'PUT',
`/sessions/${sessionId}/save-profile-on-termination/${profileName}`,
);
// validate response
validateAirtopApiResponse(this.getNode(), response);
return this.helpers.returnJsonArray({ sessionId, profileName, ...response } as IDataObject);
}

View File

@@ -0,0 +1,35 @@
import {
type IDataObject,
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
} from 'n8n-workflow';
import { validateAirtopApiResponse, validateSessionId } from '../../GenericFunctions';
import { apiRequest } from '../../transport';
import { sessionIdField } from '../common/fields';
export const description: INodeProperties[] = [
{
...sessionIdField,
displayOptions: {
show: {
resource: ['session'],
operation: ['terminate'],
},
},
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const sessionId = validateSessionId.call(this, index);
const response = await apiRequest.call(this, 'DELETE', `/sessions/${sessionId}`);
// validate response
validateAirtopApiResponse(this.getNode(), response);
return this.helpers.returnJsonArray({ success: true } as IDataObject);
}

View File

@@ -0,0 +1,72 @@
import type { INodeProperties } from 'n8n-workflow';
import * as close from './close.operation';
import * as create from './create.operation';
import * as load from './load.operation';
import * as takeScreenshot from './takeScreenshot.operation';
import { sessionIdField, windowIdField } from '../common/fields';
export { create, close, takeScreenshot, load };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
typeOptions: {
sortable: false,
},
displayOptions: {
show: {
resource: ['window'],
},
},
options: [
{
name: 'Create a New Browser Window',
value: 'create',
description: 'Create a new browser window inside a session. Can load a URL when created.',
action: 'Create a window',
},
{
name: 'Load URL',
value: 'load',
description: 'Load a URL in an existing window',
action: 'Load a page',
},
{
name: 'Take Screenshot',
value: 'takeScreenshot',
description: 'Take a screenshot of the current window',
action: 'Take screenshot',
},
{
name: 'Close Window',
value: 'close',
description: 'Close a window inside a session',
action: 'Close a window',
},
],
default: 'create',
},
{
...sessionIdField,
displayOptions: {
show: {
resource: ['window'],
},
},
},
{
...windowIdField,
displayOptions: {
show: {
resource: ['window'],
operation: ['close', 'takeScreenshot', 'load'],
},
},
},
...create.description,
...load.description,
];

View File

@@ -0,0 +1,22 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { validateAirtopApiResponse, validateSessionAndWindowId } from '../../GenericFunctions';
import { apiRequest } from '../../transport';
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
const response = await apiRequest.call(
this,
'DELETE',
`/sessions/${sessionId}/windows/${windowId}`,
);
// validate response
validateAirtopApiResponse(this.getNode(), response);
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
}

View File

@@ -0,0 +1,186 @@
import type {
IExecuteFunctions,
INodeExecutionData,
IDataObject,
INodeProperties,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import {
validateAirtopApiResponse,
validateSessionId,
validateUrl,
validateScreenResolution,
} from '../../GenericFunctions';
import { apiRequest } from '../../transport';
import type { IAirtopResponse } from '../../transport/types';
import { urlField } from '../common/fields';
export const description: INodeProperties[] = [
{
...urlField,
description: 'Initial URL to load in the window. Defaults to https://www.google.com.',
displayOptions: {
show: {
resource: ['window'],
operation: ['create'],
},
},
},
// Live View Options
{
displayName: 'Get Live View',
name: 'getLiveView',
type: 'boolean',
default: false,
description:
'Whether to get the URL of the window\'s <a href="https://docs.airtop.ai/guides/how-to/creating-a-live-view" target="_blank">Live View</a>',
displayOptions: {
show: {
resource: ['window'],
operation: ['create'],
},
},
},
{
displayName: 'Include Navigation Bar',
name: 'includeNavigationBar',
type: 'boolean',
default: false,
description:
'Whether to include the navigation bar in the Live View. When enabled, the navigation bar will be visible allowing you to navigate between pages.',
displayOptions: {
show: {
resource: ['window'],
operation: ['create'],
getLiveView: [true],
},
},
},
{
displayName: 'Screen Resolution',
name: 'screenResolution',
type: 'string',
default: '',
description:
'The screen resolution of the Live View. Setting a resolution will force the window to open at that specific size.',
placeholder: 'e.g. 1280x720',
displayOptions: {
show: {
resource: ['window'],
operation: ['create'],
getLiveView: [true],
},
},
},
{
displayName: 'Disable Resize',
name: 'disableResize',
type: 'boolean',
default: false,
description: 'Whether to disable the window from being resized in the Live View',
displayOptions: {
show: {
resource: ['window'],
operation: ['create'],
getLiveView: [true],
},
},
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['window'],
operation: ['create'],
},
},
options: [
{
displayName: 'Wait Until',
name: 'waitUntil',
type: 'options',
description: 'Wait until the specified loading event occurs',
default: 'load',
options: [
{
name: 'Load',
value: 'load',
description: "Wait until the page dom and it's assets have loaded",
},
{
name: 'DOM Content Loaded',
value: 'domContentLoaded',
description: 'Wait until the page DOM has loaded',
},
{
name: 'Complete',
value: 'complete',
description: 'Wait until all iframes in the page have loaded',
},
{
name: 'No Wait',
value: 'noWait',
description: 'Do not wait for any loading event and it will return immediately',
},
],
},
],
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const sessionId = validateSessionId.call(this, index);
const url = validateUrl.call(this, index);
const additionalFields = this.getNodeParameter('additionalFields', index);
// Live View Options
const getLiveView = this.getNodeParameter('getLiveView', index, false);
const includeNavigationBar = this.getNodeParameter('includeNavigationBar', index, false);
const screenResolution = validateScreenResolution.call(this, index);
const disableResize = this.getNodeParameter('disableResize', index, false);
let response: IAirtopResponse;
const body: IDataObject = {
url,
...additionalFields,
};
response = await apiRequest.call(this, 'POST', `/sessions/${sessionId}/windows`, body);
if (!response?.data?.windowId) {
throw new NodeApiError(this.getNode(), {
message: 'Failed to create window',
code: 500,
});
}
const windowId = String(response.data.windowId);
if (getLiveView) {
// Get Window info
response = await apiRequest.call(
this,
'GET',
`/sessions/${sessionId}/windows/${windowId}`,
undefined,
{
...(includeNavigationBar && { includeNavigationBar: true }),
...(screenResolution && { screenResolution }),
...(disableResize && { disableResize: true }),
},
);
}
// validate response
validateAirtopApiResponse(this.getNode(), response);
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
}

View File

@@ -0,0 +1,95 @@
import {
type IExecuteFunctions,
type INodeExecutionData,
type INodeProperties,
} from 'n8n-workflow';
import {
validateRequiredStringField,
validateSessionAndWindowId,
validateUrl,
validateAirtopApiResponse,
} from '../../GenericFunctions';
import { apiRequest } from '../../transport';
import { urlField } from '../common/fields';
export const description: INodeProperties[] = [
{
...urlField,
required: true,
displayOptions: {
show: {
resource: ['window'],
operation: ['load'],
},
},
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['window'],
operation: ['load'],
},
},
options: [
{
displayName: 'Wait Until',
name: 'waitUntil',
type: 'options',
default: 'load',
description: "Wait until the specified loading event occurs. Defaults to 'Fully Loaded'.",
options: [
{
name: 'Complete',
value: 'complete',
description: "Wait until the page and all it's iframes have loaded it's dom and assets",
},
{
name: 'DOM Only Loaded',
value: 'domContentLoaded',
description: 'Wait until the dom has loaded',
},
{
name: 'Fully Loaded',
value: 'load',
description: "Wait until the page dom and it's assets have loaded",
},
{
name: 'No Wait',
value: 'noWait',
description: 'Do not wait for any loading event and will return immediately',
},
],
},
],
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
let url = validateRequiredStringField.call(this, index, 'url', 'URL');
url = validateUrl.call(this, index);
const additionalFields = this.getNodeParameter('additionalFields', index);
const response = await apiRequest.call(
this,
'POST',
`/sessions/${sessionId}/windows/${windowId}`,
{
url,
waitUntil: additionalFields.waitUntil,
},
);
validateAirtopApiResponse(this.getNode(), response);
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
}

View File

@@ -0,0 +1,41 @@
import type { IExecuteFunctions, INodeExecutionData, IBinaryData } from 'n8n-workflow';
import {
validateSessionAndWindowId,
validateAirtopApiResponse,
convertScreenshotToBinary,
} from '../../GenericFunctions';
import { apiRequest } from '../../transport';
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
let data: IBinaryData | undefined; // for storing the binary data
const response = await apiRequest.call(
this,
'POST',
`/sessions/${sessionId}/windows/${windowId}/screenshot`,
);
// validate response
validateAirtopApiResponse(this.getNode(), response);
// process screenshot on success
if (response.meta?.screenshots?.length) {
const buffer = convertScreenshotToBinary(response.meta.screenshots[0]);
data = await this.helpers.prepareBinaryData(buffer, 'screenshot.jpg', 'image/jpeg');
}
return [
{
json: {
sessionId,
windowId,
...response,
},
...(data ? { binary: { data } } : {}),
},
];
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><g clip-path="url(#b)"><circle cx="16" cy="16" r="16" fill="#102626"/><g filter="url(#c)"><path stroke="#58D1EC" stroke-opacity=".2" stroke-width="21.821" d="m-24.172-33.325 41.946 51.414"/></g><path fill="url(#d)" fill-rule="evenodd" d="M12.598 9.258c1.607-2.545 5.318-2.545 6.925 0l2.33 3.69c.705 1.115-.097 2.57-1.416 2.57-2.207 0-3.088-.841-3.66-4.182l-1.423.005c-.496 3.346-1.507 4.177-4.17 4.177l-.005 1.437c2.696 0 4.175 1.495 4.175 3.454a2.94 2.94 0 0 1-2.94 2.94h-1.285c-3.225 0-5.185-3.555-3.463-6.282l4.932-7.809zm7.125 14.092a2.945 2.945 0 0 1-2.945-2.946c0-1.954 1.305-3.449 3.659-3.449h1.443c1.479 0 3.093.924 3.093 2.746 0 2.209-2.511 3.649-3.921 3.649h-1.329z" clip-rule="evenodd"/></g></g><defs><filter id="a" width="32" height="35" x="0" y="-2" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation="1"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0.345098 0 0 0 0 0.819608 0 0 0 0 0.92549 0 0 0 0.16 0"/><feBlend in2="shape" result="effect1_innerShadow_820_10455"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="-2"/><feGaussianBlur stdDeviation="1"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0.0148985 0 0 0 0 0.0926901 0 0 0 0 0.0926901 0 0 0 0.4 0"/><feBlend in2="effect1_innerShadow_820_10455" result="effect2_innerShadow_820_10455"/></filter><filter id="c" width="74.854" height="81.209" x="-40.627" y="-48.223" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_820_10455" stdDeviation="4"/></filter><radialGradient id="d" cx="0" cy="0" r="1" gradientTransform="matrix(-11.68435 11.70476 -89.05215 -88.89686 19.28 9.557)" gradientUnits="userSpaceOnUse"><stop offset=".613" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity=".8"/></radialGradient><clipPath id="b"><rect width="32" height="32" fill="#fff" rx="16"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,21 @@
export const BASE_URL = 'https://api.airtop.ai/api/v1';
export const INTEGRATION_URL = 'https://portal-api.airtop.ai/integrations/v1/no-code';
export const DEFAULT_TIMEOUT_MINUTES = 10;
export const MIN_TIMEOUT_MINUTES = 1;
export const MAX_TIMEOUT_MINUTES = 10080;
export const ERROR_MESSAGES = {
SESSION_ID_REQUIRED: "Please fill the 'Session ID' parameter",
WINDOW_ID_REQUIRED: "Please fill the 'Window ID' parameter",
URL_REQUIRED: "Please fill the 'URL' parameter",
PROFILE_NAME_INVALID: "'Profile Name' should only contain letters, numbers and dashes",
TIMEOUT_MINUTES_INVALID: `Timeout must be between ${MIN_TIMEOUT_MINUTES} and ${MAX_TIMEOUT_MINUTES} minutes`,
URL_INVALID: "'URL' must start with 'http' or 'https'",
PROFILE_NAME_REQUIRED: "'Profile Name' is required when 'Save Profile' is enabled",
REQUIRED_PARAMETER: "Please fill the '{{field}}' parameter",
PROXY_URL_REQUIRED: "Please fill the 'Proxy URL' parameter",
PROXY_URL_INVALID: "'Proxy URL' must start with 'http' or 'https'",
SCREEN_RESOLUTION_INVALID:
"'Screen Resolution' must be in the format 'width x height' (e.g. '1280x720')",
};

View File

@@ -0,0 +1,280 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import nock from 'nock';
import * as getPaginated from '../../../actions/extraction/getPaginated.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as GenericFunctions from '../../../GenericFunctions';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
const baseNodeParameters = {
resource: 'extraction',
operation: 'getPaginated',
sessionId: 'test-session-123',
windowId: 'win-123',
sessionMode: 'existing',
additionalFields: {},
};
const mockResponse = {
data: {
modelResponse:
'{"items": [{"title": "Item 1", "price": "$10.99"}, {"title": "Item 2", "price": "$20.99"}]}',
},
};
const mockJsonSchema =
'{"type":"object","properties":{"title":{"type":"string"},"price":{"type":"string"}}}';
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async (method: string, endpoint: string) => {
// For paginated extraction requests
if (endpoint.includes('/paginated-extraction')) {
return mockResponse;
}
// For session deletion
if (method === 'DELETE' && endpoint.includes('/sessions/')) {
return { status: 'success' };
}
return { success: true };
}),
};
});
jest.mock('../../../GenericFunctions', () => {
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
return {
...originalModule,
createSessionAndWindow: jest.fn().mockImplementation(async () => {
return {
sessionId: 'new-session-123',
windowId: 'new-window-123',
};
}),
shouldCreateNewSession: jest.fn().mockImplementation(function (
this: IExecuteFunctions,
index: number,
) {
const sessionMode = this.getNodeParameter('sessionMode', index) as string;
return sessionMode === 'new';
}),
validateAirtopApiResponse: jest.fn(),
};
});
describe('Test Airtop, getPaginated operation', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../transport');
jest.unmock('../../../GenericFunctions');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should extract data with minimal parameters', async () => {
const nodeParameters = {
...baseNodeParameters,
prompt: 'Extract all product titles and prices',
};
const result = await getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/paginated-extraction',
{
prompt: 'Extract all product titles and prices',
configuration: {},
},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
data: mockResponse.data,
},
},
]);
});
it('should extract data with output schema', async () => {
const nodeParameters = {
...baseNodeParameters,
prompt: 'Extract all product titles and prices',
additionalFields: {
outputSchema: mockJsonSchema,
},
};
const result = await getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/paginated-extraction',
{
prompt: 'Extract all product titles and prices',
configuration: {
outputSchema: mockJsonSchema,
},
},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
data: mockResponse.data,
},
},
]);
});
['auto', 'accurate', 'cost-efficient'].forEach((interactionMode) => {
it(`interactionMode > Should extract data with '${interactionMode}' mode`, async () => {
const nodeParameters = {
...baseNodeParameters,
prompt: 'Extract all product titles and prices',
additionalFields: {
interactionMode,
},
};
const result = await getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/paginated-extraction',
{
prompt: 'Extract all product titles and prices',
configuration: {
interactionMode,
},
},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
data: mockResponse.data,
},
},
]);
});
});
['auto', 'paginated', 'infinite-scroll'].forEach((paginationMode) => {
it(`paginationMode > Should extract data with '${paginationMode}' mode`, async () => {
const nodeParameters = {
...baseNodeParameters,
prompt: 'Extract all product titles and prices',
additionalFields: {
paginationMode,
},
};
const result = await getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/paginated-extraction',
{
prompt: 'Extract all product titles and prices',
configuration: {
paginationMode,
},
},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
data: mockResponse.data,
},
},
]);
});
});
it('should extract data using a new session', async () => {
const nodeParameters = {
...baseNodeParameters,
sessionMode: 'new',
autoTerminateSession: true,
url: 'https://example.com',
prompt: 'Extract all product titles and prices',
};
const result = await getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
expect(GenericFunctions.createSessionAndWindow).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledTimes(2); // One for extraction, one for session deletion
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/sessions/new-session-123/windows/new-window-123/paginated-extraction',
{
prompt: 'Extract all product titles and prices',
configuration: {},
},
);
expect(result).toEqual([
{
json: {
data: mockResponse.data,
},
},
]);
});
it("should throw error when 'sessionId' is empty and session mode is 'existing'", async () => {
const nodeParameters = {
...baseNodeParameters,
sessionId: '',
prompt: 'Extract data',
};
await expect(
getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0),
).rejects.toThrow(ERROR_MESSAGES.SESSION_ID_REQUIRED);
});
it("should throw error when 'windowId' is empty and session mode is 'existing'", async () => {
const nodeParameters = {
...baseNodeParameters,
windowId: '',
prompt: 'Extract data',
};
await expect(
getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0),
).rejects.toThrow(ERROR_MESSAGES.WINDOW_ID_REQUIRED);
});
});

View File

@@ -0,0 +1,196 @@
import nock from 'nock';
import * as query from '../../../actions/extraction/query.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as GenericFunctions from '../../../GenericFunctions';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
const baseNodeParameters = {
resource: 'extraction',
operation: 'query',
sessionId: 'test-session-123',
windowId: 'win-123',
sessionMode: 'existing',
};
const mockResponse = {
data: {
modelResponse: {
answer: 'The page contains 5 products with prices ranging from $10.99 to $50.99',
},
},
};
const mockJsonSchema =
'{"type":"object","properties":{"productCount":{"type":"number"},"priceRange":{"type":"object"}}}';
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function (method: string, endpoint: string) {
if (method === 'DELETE' && endpoint.includes('/sessions/')) {
return { status: 'success' };
}
return mockResponse;
}),
};
});
jest.mock('../../../GenericFunctions', () => {
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
return {
...originalModule,
createSessionAndWindow: jest.fn().mockImplementation(async () => {
return {
sessionId: 'new-session-456',
windowId: 'new-win-456',
};
}),
shouldCreateNewSession: jest.fn().mockImplementation(function (this: any) {
const sessionMode = this.getNodeParameter('sessionMode', 0);
return sessionMode === 'new';
}),
};
});
describe('Test Airtop, query page operation', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../transport');
jest.unmock('../../../GenericFunctions');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should query the page with minimal parameters using existing session', async () => {
const nodeParameters = {
...baseNodeParameters,
prompt: 'How many products are on the page and what is their price range?',
};
const result = await query.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/page-query',
{
prompt: 'How many products are on the page and what is their price range?',
configuration: {},
},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
data: mockResponse.data,
},
},
]);
});
it('should query the page with output schema using existing session', async () => {
const nodeParameters = {
...baseNodeParameters,
prompt: 'How many products are on the page and what is their price range?',
additionalFields: {
outputSchema: mockJsonSchema,
},
};
const result = await query.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/page-query',
{
prompt: 'How many products are on the page and what is their price range?',
configuration: {
outputSchema: mockJsonSchema,
},
},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
data: mockResponse.data,
},
},
]);
});
it('should query the page using a new session', async () => {
const nodeParameters = {
...baseNodeParameters,
sessionMode: 'new',
url: 'https://example.com',
prompt: 'How many products are on the page and what is their price range?',
autoTerminateSession: true,
};
const result = await query.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
expect(GenericFunctions.createSessionAndWindow).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledTimes(2); // One for query, one for session deletion
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/sessions/new-session-456/windows/new-win-456/page-query',
{
prompt: 'How many products are on the page and what is their price range?',
configuration: {},
},
);
expect(result).toEqual([
{
json: {
data: mockResponse.data,
},
},
]);
});
it("should throw error when 'sessionId' is empty in 'existing' session mode", async () => {
const nodeParameters = {
...baseNodeParameters,
sessionId: '',
prompt: 'Query data',
};
await expect(query.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.SESSION_ID_REQUIRED,
);
});
it("should throw error when 'windowId' is empty in 'existing' session mode", async () => {
const nodeParameters = {
...baseNodeParameters,
windowId: '',
prompt: 'Query data',
};
await expect(query.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.WINDOW_ID_REQUIRED,
);
});
});

View File

@@ -0,0 +1,172 @@
import nock from 'nock';
import * as scrape from '../../../actions/extraction/scrape.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as GenericFunctions from '../../../GenericFunctions';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
const baseNodeParameters = {
resource: 'extraction',
operation: 'scrape',
sessionId: 'test-session-123',
windowId: 'win-123',
sessionMode: 'existing',
};
const mockResponse = {
data: {
content: '<html><body>Scraped content</body></html>',
},
};
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function (method: string, endpoint: string) {
if (method === 'DELETE' && endpoint.includes('/sessions/')) {
return { status: 'success' };
}
return mockResponse;
}),
};
});
jest.mock('../../../GenericFunctions', () => {
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
return {
...originalModule,
createSessionAndWindow: jest.fn().mockImplementation(async () => {
return {
sessionId: 'new-session-456',
windowId: 'new-win-456',
};
}),
shouldCreateNewSession: jest.fn().mockImplementation(function (this: any) {
const sessionMode = this.getNodeParameter('sessionMode', 0);
return sessionMode === 'new';
}),
};
});
describe('Test Airtop, scrape operation', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../transport');
jest.unmock('../../../GenericFunctions');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should scrape content with minimal parameters using existing session', async () => {
const result = await scrape.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/scrape-content',
{},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
data: mockResponse.data,
},
},
]);
});
it('should scrape content with additional parameters using existing session', async () => {
const nodeParameters = {
...baseNodeParameters,
additionalFields: {
waitForSelector: '.product-list',
waitForTimeout: 5000,
},
};
const result = await scrape.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/scrape-content',
{},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
data: mockResponse.data,
},
},
]);
});
it('should scrape content using a new session', async () => {
const nodeParameters = {
...baseNodeParameters,
sessionMode: 'new',
url: 'https://example.com',
autoTerminateSession: true,
};
const result = await scrape.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
expect(GenericFunctions.createSessionAndWindow).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledTimes(2); // One for scrape, one for session deletion
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/sessions/new-session-456/windows/new-win-456/scrape-content',
{},
);
expect(result).toEqual([
{
json: {
data: mockResponse.data,
},
},
]);
});
it("should throw error when sessionId is empty in 'existing' session mode", async () => {
const nodeParameters = {
...baseNodeParameters,
sessionId: '',
};
await expect(scrape.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.SESSION_ID_REQUIRED,
);
});
it("should throw error when windowId is empty in 'existing' session mode", async () => {
const nodeParameters = {
...baseNodeParameters,
windowId: '',
};
await expect(scrape.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.WINDOW_ID_REQUIRED,
);
});
});

View File

@@ -0,0 +1,57 @@
import { get } from 'lodash';
import { constructExecutionMetaData } from 'n8n-core';
import type {
IDataObject,
IExecuteFunctions,
IGetNodeParameterOptions,
INode,
INodeExecutionData,
} from 'n8n-workflow';
export const node: INode = {
id: '1',
name: 'Airtop node',
typeVersion: 1,
type: 'n8n-nodes-base.airtop',
position: [10, 10],
parameters: {},
};
export const createMockExecuteFunction = (nodeParameters: IDataObject) => {
const fakeExecuteFunction = {
getInputData(): INodeExecutionData[] {
return [{ json: {} }];
},
getNodeParameter(
parameterName: string,
_itemIndex: number,
fallbackValue?: IDataObject | undefined,
options?: IGetNodeParameterOptions | undefined,
) {
const parameter = options?.extractValue ? `${parameterName}.value` : parameterName;
return get(nodeParameters, parameter, fallbackValue);
},
getNode() {
return node;
},
helpers: {
constructExecutionMetaData,
returnJsonArray: (data: IDataObject | IDataObject[]) => {
return [{ json: data }] as INodeExecutionData[];
},
prepareBinaryData: async (data: Buffer) => {
return {
mimeType: 'image/jpeg',
fileType: 'jpg',
fileName: 'screenshot.jpg',
data: data.toString('base64'),
};
},
},
continueOnFail: () => false,
logger: {
info: () => {},
},
} as unknown as IExecuteFunctions;
return fakeExecuteFunction;
};

View File

@@ -0,0 +1,137 @@
import nock from 'nock';
import * as click from '../../../actions/interaction/click.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
const baseNodeParameters = {
resource: 'interaction',
operation: 'click',
sessionId: 'test-session-123',
windowId: 'win-123',
elementDescription: 'the login button',
additionalFields: {},
};
const mockResponse = {
success: true,
message: 'Click executed successfully',
};
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function () {
return {
status: 'success',
data: mockResponse,
};
}),
};
});
describe('Test Airtop, click operation', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../transport');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should execute click with minimal parameters', async () => {
const result = await click.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/click',
{
elementDescription: 'the login button',
configuration: {},
},
);
expect(result).toEqual([
{
json: {
sessionId: baseNodeParameters.sessionId,
windowId: baseNodeParameters.windowId,
status: 'success',
data: mockResponse,
},
},
]);
});
it("should throw error when 'elementDescription' parameter is empty", async () => {
const nodeParameters = {
...baseNodeParameters,
elementDescription: '',
};
const errorMessage = ERROR_MESSAGES.REQUIRED_PARAMETER.replace(
'{{field}}',
'Element Description',
);
await expect(click.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
errorMessage,
);
});
it("should include 'visualScope' parameter when specified", async () => {
const nodeParameters = {
...baseNodeParameters,
additionalFields: {
visualScope: 'viewport',
},
};
await click.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/click',
{
configuration: {
visualAnalysis: {
scope: 'viewport',
},
},
elementDescription: 'the login button',
},
);
});
it("should include 'waitForNavigation' parameter when specified", async () => {
const nodeParameters = {
...baseNodeParameters,
additionalFields: {
waitForNavigation: 'load',
},
};
await click.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/click',
{
configuration: {
waitForNavigationConfig: {
waitUntil: 'load',
},
},
waitForNavigation: true,
elementDescription: 'the login button',
},
);
});
});

View File

@@ -0,0 +1,77 @@
import { constructInteractionRequest } from '../../../actions/interaction/helpers';
import { createMockExecuteFunction } from '../helpers';
describe('Test Airtop interaction helpers', () => {
describe('constructInteractionRequest', () => {
it('should construct basic request with default values', () => {
const mockExecute = createMockExecuteFunction({
additionalFields: {},
});
const request = constructInteractionRequest.call(mockExecute, 0);
expect(request).toEqual({
configuration: {},
});
});
it("should include 'visualScope' parameter when specified", () => {
const mockExecute = createMockExecuteFunction({
additionalFields: {
visualScope: 'viewport',
},
});
const request = constructInteractionRequest.call(mockExecute, 0);
expect(request).toEqual({
configuration: {
visualAnalysis: {
scope: 'viewport',
},
},
});
});
it("should include 'waitForNavigation' parameter when specified", () => {
const mockExecute = createMockExecuteFunction({
additionalFields: {
waitForNavigation: 'load',
},
});
const request = constructInteractionRequest.call(mockExecute, 0);
expect(request).toEqual({
configuration: {
waitForNavigationConfig: {
waitUntil: 'load',
},
},
waitForNavigation: true,
});
});
it('should merge additional parameters', () => {
const mockExecute = createMockExecuteFunction({
additionalFields: {
waitForNavigation: 'load',
},
});
const request = constructInteractionRequest.call(mockExecute, 0, {
elementDescription: 'test element',
});
expect(request).toEqual({
configuration: {
waitForNavigationConfig: {
waitUntil: 'load',
},
},
waitForNavigation: true,
elementDescription: 'test element',
});
});
});
});

View File

@@ -0,0 +1,137 @@
import nock from 'nock';
import * as hover from '../../../actions/interaction/hover.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
const baseNodeParameters = {
resource: 'interaction',
operation: 'hover',
sessionId: 'test-session-123',
windowId: 'win-123',
elementDescription: 'the user profile image',
additionalFields: {},
};
const mockResponse = {
success: true,
message: 'Hover interaction executed successfully',
};
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function () {
return {
status: 'success',
data: mockResponse,
};
}),
};
});
describe('Test Airtop, hover operation', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../transport');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should execute hover with minimal parameters', async () => {
const result = await hover.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/hover',
{
configuration: {},
elementDescription: 'the user profile image',
},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
status: 'success',
data: mockResponse,
},
},
]);
});
it("should throw error when 'elementDescription' parameter is empty", async () => {
const nodeParameters = {
...baseNodeParameters,
elementDescription: '',
};
const errorMessage = ERROR_MESSAGES.REQUIRED_PARAMETER.replace(
'{{field}}',
'Element Description',
);
await expect(hover.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
errorMessage,
);
});
it("should include 'visualScope' parameter when specified", async () => {
const nodeParameters = {
...baseNodeParameters,
additionalFields: {
visualScope: 'viewport',
},
};
await hover.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/hover',
{
configuration: {
visualAnalysis: {
scope: 'viewport',
},
},
elementDescription: 'the user profile image',
},
);
});
it("should include 'waitForNavigation' parameter when specified", async () => {
const nodeParameters = {
...baseNodeParameters,
additionalFields: {
waitForNavigation: 'load',
},
};
await hover.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/hover',
{
configuration: {
waitForNavigationConfig: {
waitUntil: 'load',
},
},
waitForNavigation: true,
elementDescription: 'the user profile image',
},
);
});
});

View File

@@ -0,0 +1,181 @@
import nock from 'nock';
import * as type from '../../../actions/interaction/type.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
const baseNodeParameters = {
resource: 'interaction',
operation: 'type',
sessionId: 'test-session-123',
windowId: 'win-123',
text: 'Hello World',
pressEnterKey: false,
elementDescription: '',
additionalFields: {},
};
const mockResponse = {
success: true,
message: 'Text typed successfully',
};
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function () {
return {
status: 'success',
data: mockResponse,
};
}),
};
});
describe('Test Airtop, type operation', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../transport');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should execute type with minimal parameters', async () => {
const result = await type.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/type',
{
configuration: {},
text: 'Hello World',
pressEnterKey: false,
elementDescription: '',
},
);
expect(result).toEqual([
{
json: {
sessionId: baseNodeParameters.sessionId,
windowId: baseNodeParameters.windowId,
status: 'success',
data: mockResponse,
},
},
]);
});
it("should throw error when 'text' parameter is empty", async () => {
const nodeParameters = {
...baseNodeParameters,
text: '',
};
await expect(type.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Text'),
);
});
it("should include 'elementDescription' parameter when specified", async () => {
const nodeParameters = {
...baseNodeParameters,
elementDescription: 'the search box',
};
await type.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/type',
{
configuration: {},
text: 'Hello World',
pressEnterKey: false,
elementDescription: 'the search box',
},
);
});
it("should include 'pressEnterKey' parameter when specified", async () => {
const nodeParameters = {
...baseNodeParameters,
pressEnterKey: true,
};
await type.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/type',
{
configuration: {},
text: 'Hello World',
pressEnterKey: true,
elementDescription: '',
},
);
});
it("should include 'waitForNavigation' parameter when specified", async () => {
const nodeParameters = {
...baseNodeParameters,
additionalFields: {
waitForNavigation: 'load',
},
};
await type.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/type',
{
configuration: {
waitForNavigationConfig: {
waitUntil: 'load',
},
},
waitForNavigation: true,
text: 'Hello World',
pressEnterKey: false,
elementDescription: '',
},
);
});
it("should include 'visualScope' parameter when specified", async () => {
const nodeParameters = {
...baseNodeParameters,
additionalFields: {
visualScope: 'viewport',
},
};
await type.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/type',
{
configuration: {
visualAnalysis: {
scope: 'viewport',
},
},
text: 'Hello World',
pressEnterKey: false,
elementDescription: '',
},
);
});
});

View File

@@ -0,0 +1,202 @@
import * as create from '../../../actions/session/create.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function () {
return {
sessionId: 'test-session-123',
status: 'success',
};
}),
};
});
describe('Test Airtop, session create operation', () => {
afterAll(() => {
jest.unmock('../../../transport');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a session with minimal parameters', async () => {
const nodeParameters = {
resource: 'session',
operation: 'create',
profileName: 'test-profile',
timeoutMinutes: 10,
saveProfileOnTermination: false,
proxy: 'none',
};
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'https://portal-api.airtop.ai/integrations/v1/no-code/create-session',
{
configuration: {
profileName: 'test-profile',
timeoutMinutes: 10,
proxy: false,
},
},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
},
},
]);
});
it('should create a session with save profile enabled', async () => {
const nodeParameters = {
resource: 'session',
operation: 'create',
profileName: 'test-profile',
timeoutMinutes: 15,
saveProfileOnTermination: true,
proxy: 'none',
};
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'https://portal-api.airtop.ai/integrations/v1/no-code/create-session',
{
configuration: {
profileName: 'test-profile',
timeoutMinutes: 15,
proxy: false,
},
},
);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
2,
'PUT',
'/sessions/test-session-123/save-profile-on-termination/test-profile',
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
},
},
]);
});
it('should create a session with integrated proxy', async () => {
const nodeParameters = {
resource: 'session',
operation: 'create',
profileName: 'test-profile',
timeoutMinutes: 10,
saveProfileOnTermination: false,
proxy: 'integrated',
};
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'https://portal-api.airtop.ai/integrations/v1/no-code/create-session',
{
configuration: {
profileName: 'test-profile',
timeoutMinutes: 10,
proxy: true,
},
},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
},
},
]);
});
it('should create a session with custom proxy', async () => {
const nodeParameters = {
resource: 'session',
operation: 'create',
profileName: 'test-profile',
timeoutMinutes: 10,
saveProfileOnTermination: false,
proxy: 'custom',
proxyUrl: 'http://proxy.example.com:8080',
};
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'https://portal-api.airtop.ai/integrations/v1/no-code/create-session',
{
configuration: {
profileName: 'test-profile',
timeoutMinutes: 10,
proxy: 'http://proxy.example.com:8080',
},
},
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
},
},
]);
});
it('should throw error when custom proxy URL is invalid', async () => {
const nodeParameters = {
resource: 'session',
operation: 'create',
profileName: 'test-profile',
timeoutMinutes: 10,
saveProfileOnTermination: false,
proxy: 'custom',
proxyUrl: 'invalid-url',
};
await expect(create.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.PROXY_URL_INVALID,
);
});
it('should throw error when custom proxy URL is empty', async () => {
const nodeParameters = {
resource: 'session',
operation: 'create',
profileName: 'test-profile',
timeoutMinutes: 10,
saveProfileOnTermination: false,
proxy: 'custom',
proxyUrl: '',
};
await expect(create.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.PROXY_URL_REQUIRED,
);
});
});

View File

@@ -0,0 +1,103 @@
import * as save from '../../../actions/session/save.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function () {
return {
status: 'success',
message: 'Profile will be saved on session termination',
};
}),
};
});
const baseParameters = {
resource: 'session',
operation: 'save',
sessionId: 'test-session-123',
profileName: 'test-profile',
};
describe('Test Airtop, session save operation', () => {
afterAll(() => {
jest.unmock('../../../transport');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should save a profile on session termination successfully', async () => {
const nodeParameters = {
...baseParameters,
};
const result = await save.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'PUT',
'/sessions/test-session-123/save-profile-on-termination/test-profile',
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
profileName: 'test-profile',
status: 'success',
message: 'Profile will be saved on session termination',
},
},
]);
});
it('should throw error when sessionId is empty', async () => {
const nodeParameters = {
...baseParameters,
sessionId: '',
};
await expect(save.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.SESSION_ID_REQUIRED,
);
});
it('should throw error when sessionId is whitespace', async () => {
const nodeParameters = {
...baseParameters,
sessionId: ' ',
};
await expect(save.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.SESSION_ID_REQUIRED,
);
});
it('should throw error when profileName is empty', async () => {
const nodeParameters = {
...baseParameters,
profileName: '',
};
await expect(save.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
"Please fill the 'Profile Name' parameter",
);
});
it('should throw error when profileName is whitespace', async () => {
const nodeParameters = {
...baseParameters,
profileName: ' ',
};
await expect(save.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
"Please fill the 'Profile Name' parameter",
);
});
});

View File

@@ -0,0 +1,71 @@
import * as terminate from '../../../actions/session/terminate.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function () {
return {
status: 'success',
};
}),
};
});
describe('Test Airtop, session terminate operation', () => {
afterAll(() => {
jest.unmock('../../../transport');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should terminate a session successfully', async () => {
const nodeParameters = {
resource: 'session',
operation: 'terminate',
sessionId: 'test-session-123',
};
const result = await terminate.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith('DELETE', '/sessions/test-session-123');
expect(result).toEqual([
{
json: {
success: true,
},
},
]);
});
it('should throw error when sessionId is empty', async () => {
const nodeParameters = {
resource: 'session',
operation: 'terminate',
sessionId: '',
};
await expect(
terminate.execute.call(createMockExecuteFunction(nodeParameters), 0),
).rejects.toThrow(ERROR_MESSAGES.SESSION_ID_REQUIRED);
});
it('should throw error when sessionId is whitespace', async () => {
const nodeParameters = {
resource: 'session',
operation: 'terminate',
sessionId: ' ',
};
await expect(
terminate.execute.call(createMockExecuteFunction(nodeParameters), 0),
).rejects.toThrow(ERROR_MESSAGES.SESSION_ID_REQUIRED);
});
});

View File

@@ -0,0 +1,92 @@
import nock from 'nock';
import * as close from '../../../actions/window/close.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function () {
return {
status: 'success',
data: {
closed: true,
},
};
}),
};
});
describe('Test Airtop, window close operation', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../transport');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should close a window successfully', async () => {
const nodeParameters = {
resource: 'window',
operation: 'close',
sessionId: 'test-session-123',
windowId: 'win-123',
};
const result = await close.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'DELETE',
'/sessions/test-session-123/windows/win-123',
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
status: 'success',
data: {
closed: true,
},
},
},
]);
});
it('should throw error when sessionId is empty', async () => {
const nodeParameters = {
resource: 'window',
operation: 'close',
sessionId: '',
windowId: 'win-123',
};
await expect(close.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.SESSION_ID_REQUIRED,
);
});
it('should throw error when windowId is empty', async () => {
const nodeParameters = {
resource: 'window',
operation: 'close',
sessionId: 'test-session-123',
windowId: '',
};
await expect(close.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.WINDOW_ID_REQUIRED,
);
});
});

View File

@@ -0,0 +1,308 @@
import nock from 'nock';
import * as create from '../../../actions/window/create.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
const baseNodeParameters = {
resource: 'window',
operation: 'create',
sessionId: 'test-session-123',
url: 'https://example.com',
getLiveView: false,
disableResize: false,
additionalFields: {},
};
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function (method: string) {
if (method === 'GET') {
return {
status: 'success',
data: {
liveViewUrl: 'https://live.airtop.ai/123-abcd',
},
};
}
return {
status: 'success',
data: {
windowId: 'win-123',
},
};
}),
};
});
describe('Test Airtop, window create operation', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../transport');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a window with minimal parameters', async () => {
const result = await create.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows',
{
url: 'https://example.com',
},
);
expect(result).toEqual([
{
json: {
sessionId: baseNodeParameters.sessionId,
windowId: 'win-123',
status: 'success',
data: {
windowId: 'win-123',
},
},
},
]);
});
it('should create a window with live view', async () => {
const nodeParameters = {
...baseNodeParameters,
getLiveView: true,
};
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/sessions/test-session-123/windows',
{
url: 'https://example.com',
},
);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
2,
'GET',
'/sessions/test-session-123/windows/win-123',
undefined,
{},
);
expect(result).toEqual([
{
json: {
sessionId: baseNodeParameters.sessionId,
windowId: 'win-123',
status: 'success',
data: {
liveViewUrl: 'https://live.airtop.ai/123-abcd',
},
},
},
]);
});
it('should create a window with live view and disabled resize', async () => {
const nodeParameters = {
...baseNodeParameters,
getLiveView: true,
disableResize: true,
};
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/sessions/test-session-123/windows',
{
url: 'https://example.com',
},
);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
2,
'GET',
'/sessions/test-session-123/windows/win-123',
undefined,
{ disableResize: true },
);
expect(result).toEqual([
{
json: {
sessionId: baseNodeParameters.sessionId,
windowId: 'win-123',
status: 'success',
data: {
liveViewUrl: 'https://live.airtop.ai/123-abcd',
},
},
},
]);
});
it('should create a window with live view and navigation bar', async () => {
const nodeParameters = {
...baseNodeParameters,
getLiveView: true,
includeNavigationBar: true,
};
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/sessions/test-session-123/windows',
{
url: 'https://example.com',
},
);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
2,
'GET',
'/sessions/test-session-123/windows/win-123',
undefined,
{ includeNavigationBar: true },
);
expect(result).toEqual([
{
json: {
sessionId: baseNodeParameters.sessionId,
windowId: 'win-123',
status: 'success',
data: {
liveViewUrl: 'https://live.airtop.ai/123-abcd',
},
},
},
]);
});
it('should create a window with live view and screen resolution', async () => {
const nodeParameters = {
...baseNodeParameters,
getLiveView: true,
screenResolution: '1280x720',
};
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/sessions/test-session-123/windows',
{
url: 'https://example.com',
},
);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
2,
'GET',
'/sessions/test-session-123/windows/win-123',
undefined,
{ screenResolution: '1280x720' },
);
expect(result).toEqual([
{
json: {
sessionId: baseNodeParameters.sessionId,
windowId: 'win-123',
status: 'success',
data: {
liveViewUrl: 'https://live.airtop.ai/123-abcd',
},
},
},
]);
});
it('should create a window with all live view options', async () => {
const nodeParameters = {
...baseNodeParameters,
getLiveView: true,
includeNavigationBar: true,
screenResolution: '1920x1080',
disableResize: true,
};
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/sessions/test-session-123/windows',
{
url: 'https://example.com',
},
);
expect(transport.apiRequest).toHaveBeenNthCalledWith(
2,
'GET',
'/sessions/test-session-123/windows/win-123',
undefined,
{
includeNavigationBar: true,
screenResolution: '1920x1080',
disableResize: true,
},
);
expect(result).toEqual([
{
json: {
sessionId: baseNodeParameters.sessionId,
windowId: 'win-123',
status: 'success',
data: {
liveViewUrl: 'https://live.airtop.ai/123-abcd',
},
},
},
]);
});
it("should throw error when 'sessionId' parameter is empty", async () => {
const nodeParameters = {
...baseNodeParameters,
sessionId: '',
};
await expect(create.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.SESSION_ID_REQUIRED,
);
});
it('should throw error when screen resolution format is invalid', async () => {
const nodeParameters = {
...baseNodeParameters,
getLiveView: true,
screenResolution: 'invalid-format',
};
await expect(create.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.SCREEN_RESOLUTION_INVALID,
);
});
});

View File

@@ -0,0 +1,115 @@
import nock from 'nock';
import * as load from '../../../actions/window/load.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
const baseNodeParameters = {
resource: 'window',
operation: 'load',
sessionId: 'test-session-123',
windowId: 'win-123',
url: 'https://example.com',
additionalFields: {},
};
const mockResponse = {
success: true,
message: 'Page loaded successfully',
};
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function () {
return {
status: 'success',
data: mockResponse,
};
}),
};
});
describe('Test Airtop, window load operation', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../transport');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should load URL with minimal parameters', async () => {
const result = await load.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123',
{
url: 'https://example.com',
},
);
expect(result).toEqual([
{
json: {
sessionId: baseNodeParameters.sessionId,
windowId: baseNodeParameters.windowId,
status: 'success',
data: mockResponse,
},
},
]);
});
it('should throw error when URL is empty', async () => {
const nodeParameters = {
...baseNodeParameters,
url: '',
};
const errorMessage = ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'URL');
await expect(load.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
errorMessage,
);
});
it('should throw error when URL is invalid', async () => {
const nodeParameters = {
...baseNodeParameters,
url: 'not-a-valid-url',
};
await expect(load.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
ERROR_MESSAGES.URL_INVALID,
);
});
it("should include 'waitUntil' parameter when specified", async () => {
const nodeParameters = {
...baseNodeParameters,
additionalFields: {
waitUntil: 'domContentLoaded',
},
};
await load.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123',
{
url: 'https://example.com',
waitUntil: 'domContentLoaded',
},
);
});
});

View File

@@ -0,0 +1,141 @@
import * as takeScreenshot from '../../../actions/window/takeScreenshot.operation';
import { ERROR_MESSAGES } from '../../../constants';
import * as GenericFunctions from '../../../GenericFunctions';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
const baseNodeParameters = {
resource: 'window',
operation: 'takeScreenshot',
sessionId: 'test-session-123',
windowId: 'win-123',
};
const mockResponse = {
meta: {
screenshots: [{ dataUrl: 'base64-encoded-image-data' }],
},
};
const mockBinaryBuffer = Buffer.from('mock-binary-data');
const expectedBinaryResult = {
binary: {
data: {
mimeType: 'image/jpeg',
fileType: 'jpg',
fileName: 'screenshot.jpg',
data: mockBinaryBuffer.toString('base64'),
},
},
};
jest.mock('../../../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../../../transport');
return {
...originalModule,
apiRequest: jest.fn(async function () {
return {
status: 'success',
...mockResponse,
};
}),
};
});
jest.mock('../../../GenericFunctions', () => {
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
return {
...originalModule,
convertScreenshotToBinary: jest.fn(() => mockBinaryBuffer),
};
});
describe('Test Airtop, take screenshot operation', () => {
afterAll(() => {
jest.unmock('../../../transport');
jest.unmock('../../../GenericFunctions');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should take screenshot successfully', async () => {
const result = await takeScreenshot.execute.call(
createMockExecuteFunction({ ...baseNodeParameters }),
0,
);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/screenshot',
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
status: 'success',
...mockResponse,
},
...expectedBinaryResult,
},
]);
});
it('should transform screenshot to binary data', async () => {
const result = await takeScreenshot.execute.call(
createMockExecuteFunction({
...baseNodeParameters,
}),
0,
);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/test-session-123/windows/win-123/screenshot',
);
expect(GenericFunctions.convertScreenshotToBinary).toHaveBeenCalledWith(
mockResponse.meta.screenshots[0],
);
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
windowId: 'win-123',
status: 'success',
...mockResponse,
},
...expectedBinaryResult,
},
]);
});
it('should throw error when sessionId is empty', async () => {
const nodeParameters = {
...baseNodeParameters,
sessionId: '',
};
await expect(
takeScreenshot.execute.call(createMockExecuteFunction(nodeParameters), 0),
).rejects.toThrow(ERROR_MESSAGES.SESSION_ID_REQUIRED);
});
it('should throw error when windowId is empty', async () => {
const nodeParameters = {
...baseNodeParameters,
windowId: '',
};
await expect(
takeScreenshot.execute.call(createMockExecuteFunction(nodeParameters), 0),
).rejects.toThrow(ERROR_MESSAGES.WINDOW_ID_REQUIRED);
});
});

View File

@@ -0,0 +1,201 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { createMockExecuteFunction } from './node/helpers';
import { SESSION_MODE } from '../actions/common/fields';
import { executeRequestWithSessionManagement } from '../actions/common/session.utils';
import * as transport from '../transport';
jest.mock('../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../transport');
return {
...originalModule,
apiRequest: jest.fn(async () => {
return {
success: true,
};
}),
};
});
jest.mock('../GenericFunctions', () => ({
shouldCreateNewSession: jest.fn(function (this: IExecuteFunctions, index: number) {
const sessionMode = this.getNodeParameter('sessionMode', index);
return sessionMode === SESSION_MODE.NEW;
}),
createSessionAndWindow: jest.fn(async () => ({
sessionId: 'new-session-123',
windowId: 'new-window-123',
})),
validateSessionAndWindowId: jest.fn(() => ({
sessionId: 'existing-session-123',
windowId: 'existing-window-123',
})),
validateAirtopApiResponse: jest.fn(),
}));
describe('executeRequestWithSessionManagement', () => {
afterAll(() => {
jest.unmock('../transport');
});
afterEach(() => {
jest.clearAllMocks();
});
describe("When 'sessionMode' is 'new'", () => {
it("should create a new session and window when 'sessionMode' is 'new'", async () => {
const nodeParameters = {
sessionMode: SESSION_MODE.NEW,
url: 'https://example.com',
autoTerminateSession: true,
};
const result = await executeRequestWithSessionManagement.call(
createMockExecuteFunction(nodeParameters),
0,
{
method: 'POST',
path: '/sessions/{sessionId}/windows/{windowId}/action',
body: {},
},
);
expect(result).toEqual([
{
json: {
success: true,
},
},
]);
});
it("should not terminate session when 'autoTerminateSession' is false", async () => {
const nodeParameters = {
sessionMode: SESSION_MODE.NEW,
url: 'https://example.com',
autoTerminateSession: false,
};
const result = await executeRequestWithSessionManagement.call(
createMockExecuteFunction(nodeParameters),
0,
{
method: 'POST',
path: '/sessions/{sessionId}/windows/{windowId}/action',
body: {},
},
);
expect(transport.apiRequest).not.toHaveBeenCalledWith(
'DELETE',
'/sessions/existing-session-123',
);
expect(result).toEqual([
{
json: {
sessionId: 'new-session-123',
windowId: 'new-window-123',
success: true,
},
},
]);
});
it("should terminate session when 'autoTerminateSession' is true", async () => {
const nodeParameters = {
sessionMode: SESSION_MODE.NEW,
url: 'https://example.com',
autoTerminateSession: true,
};
await executeRequestWithSessionManagement.call(createMockExecuteFunction(nodeParameters), 0, {
method: 'POST',
path: '/sessions/{sessionId}/windows/{windowId}/action',
body: {},
});
expect(transport.apiRequest).toHaveBeenNthCalledWith(
2,
'DELETE',
'/sessions/new-session-123',
);
});
it("should call the operation passed in the 'request' parameter", async () => {
const nodeParameters = {
sessionMode: SESSION_MODE.NEW,
url: 'https://example.com',
autoTerminateSession: true,
};
await executeRequestWithSessionManagement.call(createMockExecuteFunction(nodeParameters), 0, {
method: 'POST',
path: '/sessions/{sessionId}/windows/{windowId}/action',
body: {
operation: 'test-operation',
},
});
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/sessions/new-session-123/windows/new-window-123/action',
{
operation: 'test-operation',
},
);
});
});
describe("When 'sessionMode' is 'existing'", () => {
it('should not create a new session and window', async () => {
const nodeParameters = {
sessionMode: SESSION_MODE.EXISTING,
url: 'https://example.com',
sessionId: 'existing-session-123',
windowId: 'existing-window-123',
};
await executeRequestWithSessionManagement.call(createMockExecuteFunction(nodeParameters), 0, {
method: 'POST',
path: '/sessions/{sessionId}/windows/{windowId}/action',
body: {},
});
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith(
'POST',
'/sessions/existing-session-123/windows/existing-window-123/action',
{},
);
});
it("should call the operation passed in the 'request' parameter", async () => {
const nodeParameters = {
sessionMode: SESSION_MODE.EXISTING,
url: 'https://example.com',
sessionId: 'existing-session-123',
windowId: 'existing-window-123',
};
await executeRequestWithSessionManagement.call(createMockExecuteFunction(nodeParameters), 0, {
method: 'POST',
path: '/sessions/{sessionId}/windows/{windowId}/action',
body: {
operation: 'test-operation',
},
});
expect(transport.apiRequest).toHaveBeenNthCalledWith(
1,
'POST',
'/sessions/existing-session-123/windows/existing-window-123/action',
{
operation: 'test-operation',
},
);
});
});
});

View File

@@ -0,0 +1,435 @@
import { NodeApiError } from 'n8n-workflow';
import { createMockExecuteFunction } from './node/helpers';
import { ERROR_MESSAGES } from '../constants';
import {
createSessionAndWindow,
validateProfileName,
validateTimeoutMinutes,
validateSaveProfileOnTermination,
validateSessionAndWindowId,
validateAirtopApiResponse,
validateSessionId,
validateUrl,
validateRequiredStringField,
shouldCreateNewSession,
convertScreenshotToBinary,
} from '../GenericFunctions';
import type * as transport from '../transport';
jest.mock('../transport', () => {
const originalModule = jest.requireActual<typeof transport>('../transport');
return {
...originalModule,
apiRequest: jest.fn(async (method: string, endpoint: string) => {
// create session
if (endpoint.includes('/create-session')) {
return { sessionId: 'new-session-123' };
}
// create window
if (method === 'POST' && endpoint.endsWith('/windows')) {
return { data: { windowId: 'new-window-123' } };
}
return {
success: true,
};
}),
};
});
describe('Test convertScreenshotToBinary', () => {
it('should convert base64 screenshot data to buffer', () => {
const mockScreenshot = {
dataUrl: 'data:image/jpeg;base64,SGVsbG8gV29ybGQ=', // "Hello World" in base64
};
const result = convertScreenshotToBinary(mockScreenshot);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result.toString()).toBe('Hello World');
});
it('should handle empty base64 data', () => {
const mockScreenshot = {
dataUrl: 'data:image/jpeg;base64,',
};
const result = convertScreenshotToBinary(mockScreenshot);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result.length).toBe(0);
});
});
describe('Test Airtop utils', () => {
describe('validateRequiredStringField', () => {
it('should validate non-empty string field', () => {
const nodeParameters = {
testField: 'test-value',
};
const result = validateRequiredStringField.call(
createMockExecuteFunction(nodeParameters),
0,
'testField',
'Test Field',
);
expect(result).toBe('test-value');
});
it('should trim whitespace from string field', () => {
const nodeParameters = {
testField: ' test-value ',
};
const result = validateRequiredStringField.call(
createMockExecuteFunction(nodeParameters),
0,
'testField',
'Test Field',
);
expect(result).toBe('test-value');
});
it('should throw error for empty string field', () => {
const nodeParameters = {
testField: '',
};
expect(() =>
validateRequiredStringField.call(
createMockExecuteFunction(nodeParameters),
0,
'testField',
'Test Field',
),
).toThrow(ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Test Field'));
});
it('should throw error for whitespace-only string field', () => {
const nodeParameters = {
testField: ' ',
};
expect(() =>
validateRequiredStringField.call(
createMockExecuteFunction(nodeParameters),
0,
'testField',
'Test Field',
),
).toThrow(ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Test Field'));
});
});
describe('validateProfileName', () => {
it('should validate valid profile names', () => {
const nodeParameters = {
profileName: 'test-profile-123',
};
const result = validateProfileName.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe('test-profile-123');
});
it('should allow empty profile name', () => {
const nodeParameters = {
profileName: '',
};
const result = validateProfileName.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe('');
});
it('should throw error for invalid profile name', () => {
const nodeParameters = {
profileName: 'test@profile#123',
};
expect(() => validateProfileName.call(createMockExecuteFunction(nodeParameters), 0)).toThrow(
ERROR_MESSAGES.PROFILE_NAME_INVALID,
);
});
});
describe('validateTimeoutMinutes', () => {
it('should validate valid timeout', () => {
const nodeParameters = {
timeoutMinutes: 10,
};
const result = validateTimeoutMinutes.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe(10);
});
it('should throw error for timeout below minimum', () => {
const nodeParameters = {
timeoutMinutes: 0,
};
expect(() =>
validateTimeoutMinutes.call(createMockExecuteFunction(nodeParameters), 0),
).toThrow(ERROR_MESSAGES.TIMEOUT_MINUTES_INVALID);
});
it('should throw error for timeout above maximum', () => {
const nodeParameters = {
timeoutMinutes: 10081,
};
expect(() =>
validateTimeoutMinutes.call(createMockExecuteFunction(nodeParameters), 0),
).toThrow(ERROR_MESSAGES.TIMEOUT_MINUTES_INVALID);
});
});
describe('validateSaveProfileOnTermination', () => {
it('should validate when save profile is false', () => {
const nodeParameters = {
saveProfileOnTermination: false,
};
const result = validateSaveProfileOnTermination.call(
createMockExecuteFunction(nodeParameters),
0,
'',
);
expect(result).toBe(false);
});
it('should validate when save profile is true with profile name', () => {
const nodeParameters = {
saveProfileOnTermination: true,
};
const result = validateSaveProfileOnTermination.call(
createMockExecuteFunction(nodeParameters),
0,
'test-profile',
);
expect(result).toBe(true);
});
it('should throw error when save profile is true without profile name', () => {
const nodeParameters = {
saveProfileOnTermination: true,
};
expect(() =>
validateSaveProfileOnTermination.call(createMockExecuteFunction(nodeParameters), 0, ''),
).toThrow(ERROR_MESSAGES.PROFILE_NAME_REQUIRED);
});
});
describe('validateSessionId', () => {
it('should validate session ID', () => {
const nodeParameters = {
sessionId: 'test-session-123',
};
const result = validateSessionId.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe('test-session-123');
});
it('should throw error for empty session ID', () => {
const nodeParameters = {
sessionId: '',
};
expect(() => validateSessionId.call(createMockExecuteFunction(nodeParameters), 0)).toThrow(
ERROR_MESSAGES.SESSION_ID_REQUIRED,
);
});
it('should trim whitespace from session ID', () => {
const nodeParameters = {
sessionId: ' test-session-123 ',
};
const result = validateSessionId.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe('test-session-123');
});
});
describe('validateSessionAndWindowId', () => {
it('should validate session and window IDs', () => {
const nodeParameters = {
sessionId: 'test-session-123',
windowId: 'win-123',
};
const result = validateSessionAndWindowId.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toEqual({
sessionId: 'test-session-123',
windowId: 'win-123',
});
});
it('should throw error for empty session ID', () => {
const nodeParameters = {
sessionId: '',
windowId: 'win-123',
};
expect(() =>
validateSessionAndWindowId.call(createMockExecuteFunction(nodeParameters), 0),
).toThrow(ERROR_MESSAGES.SESSION_ID_REQUIRED);
});
it('should throw error for empty window ID', () => {
const nodeParameters = {
sessionId: 'test-session-123',
windowId: '',
};
expect(() =>
validateSessionAndWindowId.call(createMockExecuteFunction(nodeParameters), 0),
).toThrow(ERROR_MESSAGES.WINDOW_ID_REQUIRED);
});
it('should trim whitespace from IDs', () => {
const nodeParameters = {
sessionId: ' test-session-123 ',
windowId: ' win-123 ',
};
const result = validateSessionAndWindowId.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toEqual({
sessionId: 'test-session-123',
windowId: 'win-123',
});
});
});
describe('validateUrl', () => {
it('should validate valid URL', () => {
const nodeParameters = {
url: 'https://example.com',
};
const result = validateUrl.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe('https://example.com');
});
it('should throw error for invalid URL', () => {
const nodeParameters = {
url: 'invalid-url',
};
expect(() => validateUrl.call(createMockExecuteFunction(nodeParameters), 0)).toThrow(
ERROR_MESSAGES.URL_INVALID,
);
});
it('should return empty string for empty URL', () => {
const nodeParameters = {
url: '',
};
const result = validateUrl.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe('');
});
it('should throw error for URL without http or https', () => {
const nodeParameters = {
url: 'example.com',
};
expect(() => validateUrl.call(createMockExecuteFunction(nodeParameters), 0)).toThrow(
ERROR_MESSAGES.URL_INVALID,
);
});
});
describe('validateAirtopApiResponse', () => {
const mockNode = {
id: '1',
name: 'Airtop node',
type: 'n8n-nodes-base.airtop',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
};
it('should not throw error for valid response', () => {
const response = {
status: 'success',
data: {},
meta: {},
errors: [],
warnings: [],
};
expect(() => validateAirtopApiResponse(mockNode, response)).not.toThrow();
});
it('should throw error for response with errors', () => {
const response = {
status: 'error',
data: {},
meta: {},
errors: [{ message: 'Error 1' }, { message: 'Error 2' }],
warnings: [],
};
const expectedError = new NodeApiError(mockNode, { message: 'Error 1\nError 2' });
expect(() => validateAirtopApiResponse(mockNode, response)).toThrow(expectedError);
});
});
describe('shouldCreateNewSession', () => {
it("should return true when 'sessionMode' is 'new'", () => {
const nodeParameters = {
sessionMode: 'new',
};
const result = shouldCreateNewSession.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe(true);
});
it("should return false when 'sessionMode' is 'existing'", () => {
const nodeParameters = {
sessionMode: 'existing',
};
const result = shouldCreateNewSession.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe(false);
});
it("should return false when 'sessionMode' is empty", () => {
const nodeParameters = {
sessionMode: '',
};
const result = shouldCreateNewSession.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe(false);
});
it("should return false when 'sessionMode' is not set", () => {
const nodeParameters = {};
const result = shouldCreateNewSession.call(createMockExecuteFunction(nodeParameters), 0);
expect(result).toBe(false);
});
});
describe('createSessionAndWindow', () => {
it("should create a new session and window when sessionMode is 'new'", async () => {
const nodeParameters = {
sessionMode: 'new',
url: 'https://example.com',
};
const result = await createSessionAndWindow.call(
createMockExecuteFunction(nodeParameters),
0,
);
expect(result).toEqual({
sessionId: 'new-session-123',
windowId: 'new-window-123',
});
});
});
});

View File

@@ -0,0 +1,39 @@
import type {
IDataObject,
IExecuteFunctions,
ILoadOptionsFunctions,
IHttpRequestMethods,
IHttpRequestOptions,
} from 'n8n-workflow';
import type { IAirtopResponse } from './types';
import { BASE_URL } from '../constants';
export async function apiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
endpoint: string,
body: IDataObject = {},
query: IDataObject = {},
) {
const options: IHttpRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
body,
qs: query,
url: endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`,
json: true,
};
if (Object.keys(body).length === 0) {
delete options.body;
}
return (await this.helpers.httpRequestWithAuthentication.call(
this,
'airtopApi',
options,
)) as IAirtopResponse;
}

View File

@@ -0,0 +1,29 @@
import type { IDataObject } from 'n8n-workflow';
export interface IAirtopResponse extends IDataObject {
sessionId?: string;
data: IDataObject & {
modelResponse?: string;
};
meta: IDataObject & {
status?: string;
screenshots?: Array<{ dataUrl: string }>;
};
errors: IDataObject[];
warnings: IDataObject[];
}
export interface IAirtopInteractionRequest extends IDataObject {
text?: string;
waitForNavigation?: boolean;
elementDescription?: string;
pressEnterKey?: boolean;
configuration: {
visualAnalysis?: {
scope: string;
};
waitForNavigationConfig?: {
waitUntil: string;
};
};
}

View File

@@ -31,6 +31,7 @@
"dist/credentials/AirtableApi.credentials.js",
"dist/credentials/AirtableOAuth2Api.credentials.js",
"dist/credentials/AirtableTokenApi.credentials.js",
"dist/credentials/AirtopApi.credentials.js",
"dist/credentials/AlienVaultApi.credentials.js",
"dist/credentials/Amqp.credentials.js",
"dist/credentials/ApiTemplateIoApi.credentials.js",
@@ -413,6 +414,7 @@
"dist/nodes/AgileCrm/AgileCrm.node.js",
"dist/nodes/Airtable/Airtable.node.js",
"dist/nodes/Airtable/AirtableTrigger.node.js",
"dist/nodes/Airtop/Airtop.node.js",
"dist/nodes/AiTransform/AiTransform.node.js",
"dist/nodes/Amqp/Amqp.node.js",
"dist/nodes/Amqp/AmqpTrigger.node.js",