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",