diff --git a/packages/nodes-base/credentials/AirtopApi.credentials.ts b/packages/nodes-base/credentials/AirtopApi.credentials.ts new file mode 100644 index 0000000000..75afe9764a --- /dev/null +++ b/packages/nodes-base/credentials/AirtopApi.credentials.ts @@ -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 Airtop 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, + }, + }, + }; +} diff --git a/packages/nodes-base/nodes/Airtop/Airtop.node.json b/packages/nodes-base/nodes/Airtop/Airtop.node.json new file mode 100644 index 0000000000..8050ecdf40 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/Airtop.node.json @@ -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/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Airtop/Airtop.node.ts b/packages/nodes-base/nodes/Airtop/Airtop.node.ts new file mode 100644 index 0000000000..526a83dbcf --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/Airtop.node.ts @@ -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); + } +} diff --git a/packages/nodes-base/nodes/Airtop/GenericFunctions.ts b/packages/nodes-base/nodes/Airtop/GenericFunctions.ts new file mode 100644 index 0000000000..b92e4915b6 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/GenericFunctions.ts @@ -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 }; +} diff --git a/packages/nodes-base/nodes/Airtop/actions/common/fields.ts b/packages/nodes-base/nodes/Airtop/actions/common/fields.ts new file mode 100644 index 0000000000..71ee973733 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/common/fields.ts @@ -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 Session to use', +}; + +export const windowIdField: INodeProperties = { + displayName: 'Window ID', + name: 'windowId', + type: 'string', + required: true, + default: '={{ $json["windowId"] }}', + description: + 'The ID of the Window 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: 'Learn more 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 Airtop API Playground.', + 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], + }, + }, + }, + ]; +} diff --git a/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts b/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts new file mode 100644 index 0000000000..b3d33287f7 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts @@ -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 { + 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 }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/extraction/Extraction.resource.ts b/packages/nodes-base/nodes/Airtop/actions/extraction/Extraction.resource.ts new file mode 100644 index 0000000000..f7513b2deb --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/extraction/Extraction.resource.ts @@ -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, +]; diff --git a/packages/nodes-base/nodes/Airtop/actions/extraction/getPaginated.operation.ts b/packages/nodes-base/nodes/Airtop/actions/extraction/getPaginated.operation.ts new file mode 100644 index 0000000000..16edfcccea --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/extraction/getPaginated.operation.ts @@ -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 { + 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, + }, + }, + }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/extraction/query.operation.ts b/packages/nodes-base/nodes/Airtop/actions/extraction/query.operation.ts new file mode 100644 index 0000000000..9f966aa5b3 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/extraction/query.operation.ts @@ -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 { + 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, + }, + }, + }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/extraction/scrape.operation.ts b/packages/nodes-base/nodes/Airtop/actions/extraction/scrape.operation.ts new file mode 100644 index 0000000000..574bea5292 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/extraction/scrape.operation.ts @@ -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 { + return await executeRequestWithSessionManagement.call(this, index, { + method: 'POST', + path: '/sessions/{sessionId}/windows/{windowId}/scrape-content', + body: {}, + }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/interaction/Interaction.resource.ts b/packages/nodes-base/nodes/Airtop/actions/interaction/Interaction.resource.ts new file mode 100644 index 0000000000..2a0ecda3da --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/interaction/Interaction.resource.ts @@ -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', + }, + ], + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Airtop/actions/interaction/click.operation.ts b/packages/nodes-base/nodes/Airtop/actions/interaction/click.operation.ts new file mode 100644 index 0000000000..6aa4e7283f --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/interaction/click.operation.ts @@ -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 { + 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 }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/interaction/helpers.ts b/packages/nodes-base/nodes/Airtop/actions/interaction/helpers.ts new file mode 100644 index 0000000000..bdcbd1dec1 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/interaction/helpers.ts @@ -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 { + 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; +} diff --git a/packages/nodes-base/nodes/Airtop/actions/interaction/hover.operation.ts b/packages/nodes-base/nodes/Airtop/actions/interaction/hover.operation.ts new file mode 100644 index 0000000000..db46dd475f --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/interaction/hover.operation.ts @@ -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 { + 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 }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/interaction/type.operation.ts b/packages/nodes-base/nodes/Airtop/actions/interaction/type.operation.ts new file mode 100644 index 0000000000..b43ca2b9c6 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/interaction/type.operation.ts @@ -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 { + 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 }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/node.type.ts b/packages/nodes-base/nodes/Airtop/actions/node.type.ts new file mode 100644 index 0000000000..6175d22a56 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/node.type.ts @@ -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; diff --git a/packages/nodes-base/nodes/Airtop/actions/router.ts b/packages/nodes-base/nodes/Airtop/actions/router.ts new file mode 100644 index 0000000000..826001010f --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/router.ts @@ -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 { + const operationResult: INodeExecutionData[] = []; + let responseData: IDataObject | IDataObject[] = []; + + const items = this.getInputData(); + const resource = this.getNodeParameter('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]; +} diff --git a/packages/nodes-base/nodes/Airtop/actions/session/Session.resource.ts b/packages/nodes-base/nodes/Airtop/actions/session/Session.resource.ts new file mode 100644 index 0000000000..367cdf8d46 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/session/Session.resource.ts @@ -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, +]; diff --git a/packages/nodes-base/nodes/Airtop/actions/session/create.operation.ts b/packages/nodes-base/nodes/Airtop/actions/session/create.operation.ts new file mode 100644 index 0000000000..40e3161bbf --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/session/create.operation.ts @@ -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 Airtop profile 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 { + 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); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/session/save.operation.ts b/packages/nodes-base/nodes/Airtop/actions/session/save.operation.ts new file mode 100644 index 0000000000..f81904b1e3 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/session/save.operation.ts @@ -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 Profile 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 { + 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); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/session/terminate.operation.ts b/packages/nodes-base/nodes/Airtop/actions/session/terminate.operation.ts new file mode 100644 index 0000000000..66bfc52c0f --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/session/terminate.operation.ts @@ -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 { + 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); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts b/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts new file mode 100644 index 0000000000..524b85071c --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts @@ -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, +]; diff --git a/packages/nodes-base/nodes/Airtop/actions/window/close.operation.ts b/packages/nodes-base/nodes/Airtop/actions/window/close.operation.ts new file mode 100644 index 0000000000..1b08f06ac3 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/window/close.operation.ts @@ -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 { + 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 }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/window/create.operation.ts b/packages/nodes-base/nodes/Airtop/actions/window/create.operation.ts new file mode 100644 index 0000000000..1a13413a33 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/window/create.operation.ts @@ -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 Live View', + 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 { + 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 }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/window/load.operation.ts b/packages/nodes-base/nodes/Airtop/actions/window/load.operation.ts new file mode 100644 index 0000000000..548cb0357b --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/window/load.operation.ts @@ -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 { + 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 }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/window/takeScreenshot.operation.ts b/packages/nodes-base/nodes/Airtop/actions/window/takeScreenshot.operation.ts new file mode 100644 index 0000000000..5ad0294571 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/window/takeScreenshot.operation.ts @@ -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 { + 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 } } : {}), + }, + ]; +} diff --git a/packages/nodes-base/nodes/Airtop/airtop.svg b/packages/nodes-base/nodes/Airtop/airtop.svg new file mode 100644 index 0000000000..91f9c93b63 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/airtop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Airtop/constants.ts b/packages/nodes-base/nodes/Airtop/constants.ts new file mode 100644 index 0000000000..6380c5869c --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/constants.ts @@ -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')", +}; diff --git a/packages/nodes-base/nodes/Airtop/test/node/extraction/getPaginated.test.ts b/packages/nodes-base/nodes/Airtop/test/node/extraction/getPaginated.test.ts new file mode 100644 index 0000000000..2667000fc7 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/extraction/getPaginated.test.ts @@ -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('../../../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('../../../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); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/extraction/query.test.ts b/packages/nodes-base/nodes/Airtop/test/node/extraction/query.test.ts new file mode 100644 index 0000000000..f3a0bd3f3b --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/extraction/query.test.ts @@ -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('../../../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('../../../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, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/extraction/scrape.test.ts b/packages/nodes-base/nodes/Airtop/test/node/extraction/scrape.test.ts new file mode 100644 index 0000000000..1b053539de --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/extraction/scrape.test.ts @@ -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: 'Scraped content', + }, +}; + +jest.mock('../../../transport', () => { + const originalModule = jest.requireActual('../../../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('../../../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, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/helpers.ts b/packages/nodes-base/nodes/Airtop/test/node/helpers.ts new file mode 100644 index 0000000000..ba231bfe50 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/helpers.ts @@ -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; +}; diff --git a/packages/nodes-base/nodes/Airtop/test/node/interaction/click.test.ts b/packages/nodes-base/nodes/Airtop/test/node/interaction/click.test.ts new file mode 100644 index 0000000000..5cada782be --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/interaction/click.test.ts @@ -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('../../../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', + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/interaction/helpers.test.ts b/packages/nodes-base/nodes/Airtop/test/node/interaction/helpers.test.ts new file mode 100644 index 0000000000..d4c3353f5a --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/interaction/helpers.test.ts @@ -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', + }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/interaction/hover.test.ts b/packages/nodes-base/nodes/Airtop/test/node/interaction/hover.test.ts new file mode 100644 index 0000000000..0f91917259 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/interaction/hover.test.ts @@ -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('../../../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', + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/interaction/type.test.ts b/packages/nodes-base/nodes/Airtop/test/node/interaction/type.test.ts new file mode 100644 index 0000000000..b5471dfaec --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/interaction/type.test.ts @@ -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('../../../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: '', + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/session/create.test.ts b/packages/nodes-base/nodes/Airtop/test/node/session/create.test.ts new file mode 100644 index 0000000000..04a7d34127 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/session/create.test.ts @@ -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('../../../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, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/session/save.test.ts b/packages/nodes-base/nodes/Airtop/test/node/session/save.test.ts new file mode 100644 index 0000000000..115354e06a --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/session/save.test.ts @@ -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('../../../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", + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/session/terminate.test.ts b/packages/nodes-base/nodes/Airtop/test/node/session/terminate.test.ts new file mode 100644 index 0000000000..ede8cd0757 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/session/terminate.test.ts @@ -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('../../../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); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/window/close.test.ts b/packages/nodes-base/nodes/Airtop/test/node/window/close.test.ts new file mode 100644 index 0000000000..5d023d2c06 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/window/close.test.ts @@ -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('../../../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, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/window/create.test.ts b/packages/nodes-base/nodes/Airtop/test/node/window/create.test.ts new file mode 100644 index 0000000000..8dedc11452 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/window/create.test.ts @@ -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('../../../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, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/window/load.test.ts b/packages/nodes-base/nodes/Airtop/test/node/window/load.test.ts new file mode 100644 index 0000000000..289e393627 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/window/load.test.ts @@ -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('../../../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', + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/window/takeScreenshot.test.ts b/packages/nodes-base/nodes/Airtop/test/node/window/takeScreenshot.test.ts new file mode 100644 index 0000000000..1d4f2337c0 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/window/takeScreenshot.test.ts @@ -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('../../../transport'); + return { + ...originalModule, + apiRequest: jest.fn(async function () { + return { + status: 'success', + ...mockResponse, + }; + }), + }; +}); + +jest.mock('../../../GenericFunctions', () => { + const originalModule = jest.requireActual('../../../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); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/session-utils.test.ts b/packages/nodes-base/nodes/Airtop/test/session-utils.test.ts new file mode 100644 index 0000000000..a42c655721 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/session-utils.test.ts @@ -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('../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', + }, + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/utils.test.ts b/packages/nodes-base/nodes/Airtop/test/utils.test.ts new file mode 100644 index 0000000000..08f744a1c2 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/utils.test.ts @@ -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('../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', + }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/transport/index.ts b/packages/nodes-base/nodes/Airtop/transport/index.ts new file mode 100644 index 0000000000..a4b1cbad0e --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/transport/index.ts @@ -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; +} diff --git a/packages/nodes-base/nodes/Airtop/transport/types.ts b/packages/nodes-base/nodes/Airtop/transport/types.ts new file mode 100644 index 0000000000..18139fba22 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/transport/types.ts @@ -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; + }; + }; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index da00638650..8700afb86a 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -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",