diff --git a/packages/nodes-base/credentials/AirtopApi.credentials.ts b/packages/nodes-base/credentials/AirtopApi.credentials.ts index 75afe9764a..040d513281 100644 --- a/packages/nodes-base/credentials/AirtopApi.credentials.ts +++ b/packages/nodes-base/credentials/AirtopApi.credentials.ts @@ -5,6 +5,8 @@ import type { INodeProperties, } from 'n8n-workflow'; +import { BASE_URL } from '../nodes/Airtop/constants'; + export class AirtopApi implements ICredentialType { name = 'airtopApi'; @@ -41,7 +43,7 @@ export class AirtopApi implements ICredentialType { test: ICredentialTestRequest = { request: { method: 'GET', - baseURL: 'https://api.airtop.ai/api/v1', + baseURL: BASE_URL, url: '/sessions', qs: { limit: 10, diff --git a/packages/nodes-base/nodes/Airtop/Airtop.node.ts b/packages/nodes-base/nodes/Airtop/Airtop.node.ts index 526a83dbcf..44a42b62c9 100644 --- a/packages/nodes-base/nodes/Airtop/Airtop.node.ts +++ b/packages/nodes-base/nodes/Airtop/Airtop.node.ts @@ -2,10 +2,12 @@ import { NodeConnectionTypes } from 'n8n-workflow'; import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-workflow'; import * as extraction from './actions/extraction/Extraction.resource'; +import * as file from './actions/file/File.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', @@ -35,6 +37,18 @@ export class Airtop implements INodeType { type: 'options', noDataExpression: true, options: [ + { + name: 'Extraction', + value: 'extraction', + }, + { + name: 'File', + value: 'file', + }, + { + name: 'Interaction', + value: 'interaction', + }, { name: 'Session', value: 'session', @@ -43,19 +57,12 @@ export class Airtop implements INodeType { name: 'Window', value: 'window', }, - { - name: 'Extraction', - value: 'extraction', - }, - { - name: 'Interaction', - value: 'interaction', - }, ], default: 'session', }, ...session.description, ...window.description, + ...file.description, ...extraction.description, ...interaction.description, ], diff --git a/packages/nodes-base/nodes/Airtop/GenericFunctions.ts b/packages/nodes-base/nodes/Airtop/GenericFunctions.ts index b92e4915b6..8535a5a922 100644 --- a/packages/nodes-base/nodes/Airtop/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Airtop/GenericFunctions.ts @@ -1,16 +1,18 @@ -import { NodeApiError, type IExecuteFunctions, type INode } from 'n8n-workflow'; +import { NodeApiError, type IExecuteFunctions, type INode, type IDataObject } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; import { SESSION_MODE } from './actions/common/fields'; +import type { TScrollingMode } from './constants'; import { ERROR_MESSAGES, DEFAULT_TIMEOUT_MINUTES, MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES, - INTEGRATION_URL, + SESSION_STATUS, + OPERATION_TIMEOUT, } from './constants'; import { apiRequest } from './transport'; -import type { IAirtopResponse } from './transport/types'; +import type { IAirtopResponse, IAirtopSessionResponse } from './transport/types'; /** * Validate a required string field @@ -25,7 +27,7 @@ export function validateRequiredStringField( field: string, fieldName: string, ) { - let value = this.getNodeParameter(field, index) as string; + let value = this.getNodeParameter(field, index, '') as string; value = (value || '').trim(); const errorMessage = ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', fieldName); @@ -156,34 +158,113 @@ export function validateUrl(this: IExecuteFunctions, index: number) { } /** - * Validate the Proxy URL parameter + * Validate the Proxy configuration * @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 + * @returns The validated proxy configuration */ -export function validateProxyUrl(this: IExecuteFunctions, index: number, proxy: string) { - let proxyUrl = this.getNodeParameter('proxyUrl', index, '') as string; - proxyUrl = (proxyUrl || '').trim(); +export function validateProxy(this: IExecuteFunctions, index: number) { + const proxyParam = this.getNodeParameter('proxy', index, '') as + | 'none' + | 'integrated' + | 'proxyUrl'; + const proxyConfig = this.getNodeParameter('proxyConfig', index, '') as { + country: string; + sticky: boolean; + }; + const isConfigEmpty = Object.keys(proxyConfig).length === 0; - // only validate proxyUrl if proxy is custom - if (proxy !== 'custom') { - return ''; + if (proxyParam === 'integrated') { + return { + proxy: isConfigEmpty ? true : { ...proxyConfig }, + }; } - if (!proxyUrl) { - throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.PROXY_URL_REQUIRED, { + // handle custom proxy configuration + if (proxyParam === 'proxyUrl') { + return { + proxy: validateRequiredStringField.call(this, index, 'proxyUrl', 'Proxy URL'), + }; + } + + return { + proxy: false, + }; +} + +/** + * Validate the scrollBy amount parameter + * @param this - The execution context + * @param index - The index of the node + * @param parameterName - The name of the parameter + * @returns The validated scrollBy amount + */ +export function validateScrollByAmount( + this: IExecuteFunctions, + index: number, + parameterName: string, +) { + const regex = /^(?:-?\d{1,3}(?:%|px))$/; + const scrollBy = this.getNodeParameter(parameterName, index, {}) as { + xAxis?: string; + yAxis?: string; + }; + + if (!scrollBy?.xAxis && !scrollBy?.yAxis) { + return {}; + } + + const allAxisValid = [scrollBy.xAxis, scrollBy.yAxis] + .filter(Boolean) + .every((axis) => regex.test(axis ?? '')); + + if (!allAxisValid) { + throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SCROLL_BY_AMOUNT_INVALID, { itemIndex: index, }); } - if (!proxyUrl.startsWith('http')) { - throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.PROXY_URL_INVALID, { + return scrollBy; +} + +/** + * Validate the scroll mode parameter + * @param this - The execution context + * @param index - The index of the node + * @returns Scroll mode + * @throws Error if the scroll mode or scroll parameters are invalid + */ +export function validateScrollingMode(this: IExecuteFunctions, index: number): TScrollingMode { + const scrollingMode = this.getNodeParameter( + 'scrollingMode', + index, + 'automatic', + ) as TScrollingMode; + + const scrollToEdge = this.getNodeParameter('scrollToEdge.edgeValues', index, {}) as { + xAxis?: string; + yAxis?: string; + }; + const scrollBy = this.getNodeParameter('scrollBy.scrollValues', index, {}) as { + xAxis?: string; + yAxis?: string; + }; + + if (scrollingMode !== 'manual') { + return scrollingMode; + } + + // validate manual scroll parameters + const emptyScrollBy = !scrollBy.xAxis && !scrollBy.yAxis; + const emptyScrollToEdge = !scrollToEdge.xAxis && !scrollToEdge.yAxis; + + if (emptyScrollBy && emptyScrollToEdge) { + throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SCROLL_MODE_INVALID, { itemIndex: index, }); } - return proxyUrl; + return scrollingMode; } /** @@ -273,6 +354,56 @@ export function shouldCreateNewSession(this: IExecuteFunctions, index: number) { return Boolean(sessionMode && sessionMode === SESSION_MODE.NEW); } +/** + * Create a new session and wait until the session is ready + * @param this - The execution context + * @param parameters - The parameters for the session + * @returns The session ID + */ +export async function createSession( + this: IExecuteFunctions, + parameters: IDataObject, + timeout = OPERATION_TIMEOUT, +): Promise<{ sessionId: string }> { + // Request session creation + const response = (await apiRequest.call( + this, + 'POST', + '/sessions', + parameters, + )) as IAirtopSessionResponse; + const sessionId = response?.data?.id; + + if (!sessionId) { + throw new NodeApiError(this.getNode(), { + message: 'Failed to create session', + code: 500, + }); + } + + // Poll until the session is ready or timeout is reached + let sessionStatus = response?.data?.status; + const startTime = Date.now(); + + while (sessionStatus !== SESSION_STATUS.RUNNING) { + if (Date.now() - startTime > timeout) { + throw new NodeApiError(this.getNode(), { + message: ERROR_MESSAGES.TIMEOUT_REACHED, + code: 500, + }); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + const sessionStatusResponse = (await apiRequest.call( + this, + 'GET', + `/sessions/${sessionId}`, + )) as IAirtopSessionResponse; + sessionStatus = sessionStatusResponse.data.status; + } + + return { sessionId }; +} + /** * Create a new session and window * @param this - The execution context @@ -284,11 +415,10 @@ export async function createSessionAndWindow( 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, { + const { sessionId } = await createSession.call(this, { configuration: { profileName, }, diff --git a/packages/nodes-base/nodes/Airtop/actions/common/fields.ts b/packages/nodes-base/nodes/Airtop/actions/common/fields.ts index 71ee973733..828b2b198e 100644 --- a/packages/nodes-base/nodes/Airtop/actions/common/fields.ts +++ b/packages/nodes-base/nodes/Airtop/actions/common/fields.ts @@ -61,6 +61,15 @@ export const outputSchemaField: INodeProperties = { default: '', }; +export const parseJsonOutputField: INodeProperties = { + displayName: 'Parse JSON Output', + name: 'parseJsonOutput', + type: 'boolean', + default: true, + description: + "Whether to parse the model's response to JSON in the output. Requires the 'JSON Output Schema' parameter to be set.", +}; + /** * Interaction related fields */ diff --git a/packages/nodes-base/nodes/Airtop/actions/common/output.utils.ts b/packages/nodes-base/nodes/Airtop/actions/common/output.utils.ts new file mode 100644 index 0000000000..c01e7564fa --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/common/output.utils.ts @@ -0,0 +1,80 @@ +import { NodeOperationError } from 'n8n-workflow'; +import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; + +import type { IAirtopNodeExecutionData, IAirtopResponse } from '../../transport/types'; + +/** + * Parse JSON when the 'Parse JSON Output' parameter is enabled + * @param this - The execution context + * @param index - The index of the node + * @param response - The Airtop API response to parse + * @returns The parsed output + */ +export function parseJsonIfPresent( + this: IExecuteFunctions, + index: number, + response: IAirtopResponse, +): IAirtopResponse { + const parseJsonOutput = this.getNodeParameter('additionalFields.parseJsonOutput', index, false); + const outputJsonSchema = this.getNodeParameter( + 'additionalFields.outputSchema', + index, + '', + ) as string; + + if (!parseJsonOutput || !outputJsonSchema.startsWith('{')) { + return response; + } + + try { + const output = JSON.parse(response.data?.modelResponse ?? '') as IDataObject; + return { + sessionId: response.sessionId, + windowId: response.windowId, + output, + }; + } catch (error) { + throw new NodeOperationError(this.getNode(), 'Output is not a valid JSON'); + } +} + +/** + * Clean up the output when used as a tool + * @param output - The output to clean up + * @returns The cleaned up output + */ +export function cleanOutputForToolUse(output: IAirtopNodeExecutionData[]) { + const getOutput = (executionData: IAirtopNodeExecutionData) => { + // Return error message + if (executionData.json?.errors?.length) { + const errorMessage = executionData.json?.errors[0].message as string; + return { + output: `Error: ${errorMessage}`, + }; + } + + // Return output parsed from JSON + if (executionData.json?.output) { + return executionData.json?.output; + } + + // Return model response + if (executionData.json?.data?.modelResponse) { + return { + output: executionData.json?.data?.modelResponse, + }; + } + + // Return everything else + return { + output: { ...(executionData.json?.data ?? {}) }, + }; + }; + + return output.map((executionData) => ({ + ...executionData, + json: { + ...getOutput(executionData), + }, + })); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts b/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts index b3d33287f7..158f00587c 100644 --- a/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts +++ b/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts @@ -1,4 +1,4 @@ -import type { INodeExecutionData, IExecuteFunctions, IDataObject } from 'n8n-workflow'; +import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; import { validateSessionAndWindowId, @@ -7,6 +7,7 @@ import { validateAirtopApiResponse, } from '../../GenericFunctions'; import { apiRequest } from '../../transport'; +import type { IAirtopResponse } from '../../transport/types'; /** * Execute the node operation. Creates and terminates a new session if needed. @@ -23,7 +24,7 @@ export async function executeRequestWithSessionManagement( path: string; body: IDataObject; }, -): Promise { +): Promise { const { sessionId, windowId } = shouldCreateNewSession.call(this, index) ? await createSessionAndWindow.call(this, index) : validateSessionAndWindowId.call(this, index); @@ -38,8 +39,8 @@ export async function executeRequestWithSessionManagement( if (shouldTerminateSession) { await apiRequest.call(this, 'DELETE', `/sessions/${sessionId}`); this.logger.info(`[${this.getNode().name}] Session terminated.`); - return this.helpers.returnJsonArray({ ...response }); + return response; } - return this.helpers.returnJsonArray({ sessionId, windowId, ...response }); + return { sessionId, windowId, ...response }; } diff --git a/packages/nodes-base/nodes/Airtop/actions/extraction/getPaginated.operation.ts b/packages/nodes-base/nodes/Airtop/actions/extraction/getPaginated.operation.ts index 16edfcccea..8c74ab7c07 100644 --- a/packages/nodes-base/nodes/Airtop/actions/extraction/getPaginated.operation.ts +++ b/packages/nodes-base/nodes/Airtop/actions/extraction/getPaginated.operation.ts @@ -4,7 +4,8 @@ import { type INodeProperties, } from 'n8n-workflow'; -import { outputSchemaField } from '../common/fields'; +import { outputSchemaField, parseJsonOutputField } from '../common/fields'; +import { parseJsonIfPresent } from '../common/output.utils'; import { executeRequestWithSessionManagement } from '../common/session.utils'; export const description: INodeProperties[] = [ @@ -42,6 +43,9 @@ export const description: INodeProperties[] = [ { ...outputSchemaField, }, + { + ...parseJsonOutputField, + }, { displayName: 'Interaction Mode', name: 'interactionMode', @@ -101,14 +105,21 @@ export async function execute( const prompt = this.getNodeParameter('prompt', index, '') as string; const additionalFields = this.getNodeParameter('additionalFields', index); - return await executeRequestWithSessionManagement.call(this, index, { + const configFields = ['paginationMode', 'interactionMode', 'outputSchema']; + const configuration = configFields.reduce( + (config, key) => (additionalFields[key] ? { ...config, [key]: additionalFields[key] } : config), + {}, + ); + + const result = await executeRequestWithSessionManagement.call(this, index, { method: 'POST', path: '/sessions/{sessionId}/windows/{windowId}/paginated-extraction', body: { prompt, - configuration: { - ...additionalFields, - }, + configuration, }, }); + + const nodeOutput = parseJsonIfPresent.call(this, index, result); + return this.helpers.returnJsonArray(nodeOutput); } diff --git a/packages/nodes-base/nodes/Airtop/actions/extraction/query.operation.ts b/packages/nodes-base/nodes/Airtop/actions/extraction/query.operation.ts index 9f966aa5b3..4a1867f783 100644 --- a/packages/nodes-base/nodes/Airtop/actions/extraction/query.operation.ts +++ b/packages/nodes-base/nodes/Airtop/actions/extraction/query.operation.ts @@ -4,7 +4,8 @@ import { type INodeProperties, } from 'n8n-workflow'; -import { outputSchemaField } from '../common/fields'; +import { outputSchemaField, parseJsonOutputField } from '../common/fields'; +import { parseJsonIfPresent } from '../common/output.utils'; import { executeRequestWithSessionManagement } from '../common/session.utils'; export const description: INodeProperties[] = [ @@ -42,6 +43,16 @@ export const description: INodeProperties[] = [ { ...outputSchemaField, }, + { + ...parseJsonOutputField, + }, + { + displayName: 'Include Visual Analysis', + name: 'includeVisualAnalysis', + type: 'boolean', + default: false, + description: 'Whether to analyze the web page visually when fulfilling the request', + }, ], }, ]; @@ -51,16 +62,24 @@ export async function execute( index: number, ): Promise { const prompt = this.getNodeParameter('prompt', index, '') as string; - const additionalFields = this.getNodeParameter('additionalFields', index); + const additionalFields = this.getNodeParameter('additionalFields', index, {}); + const outputSchema = additionalFields.outputSchema; + const includeVisualAnalysis = additionalFields.includeVisualAnalysis; - return await executeRequestWithSessionManagement.call(this, index, { + const result = await executeRequestWithSessionManagement.call(this, index, { method: 'POST', path: '/sessions/{sessionId}/windows/{windowId}/page-query', body: { prompt, configuration: { - ...additionalFields, + experimental: { + includeVisualAnalysis: includeVisualAnalysis ? 'enabled' : 'disabled', + }, + ...(outputSchema ? { outputSchema } : {}), }, }, }); + + const nodeOutput = parseJsonIfPresent.call(this, index, result); + return this.helpers.returnJsonArray(nodeOutput); } diff --git a/packages/nodes-base/nodes/Airtop/actions/extraction/scrape.operation.ts b/packages/nodes-base/nodes/Airtop/actions/extraction/scrape.operation.ts index 574bea5292..fa797ac671 100644 --- a/packages/nodes-base/nodes/Airtop/actions/extraction/scrape.operation.ts +++ b/packages/nodes-base/nodes/Airtop/actions/extraction/scrape.operation.ts @@ -6,9 +6,11 @@ export async function execute( this: IExecuteFunctions, index: number, ): Promise { - return await executeRequestWithSessionManagement.call(this, index, { + const result = await executeRequestWithSessionManagement.call(this, index, { method: 'POST', path: '/sessions/{sessionId}/windows/{windowId}/scrape-content', body: {}, }); + + return this.helpers.returnJsonArray({ ...result }); } diff --git a/packages/nodes-base/nodes/Airtop/actions/file/File.resource.ts b/packages/nodes-base/nodes/Airtop/actions/file/File.resource.ts new file mode 100644 index 0000000000..1f8702c73f --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/file/File.resource.ts @@ -0,0 +1,61 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as deleteFile from './delete.operation'; +import * as get from './get.operation'; +import * as getMany from './getMany.operation'; +import * as load from './load.operation'; +import * as upload from './upload.operation'; + +export { deleteFile, get, getMany, upload, load }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['file'], + }, + }, + options: [ + { + name: 'Delete', + value: 'deleteFile', + description: 'Delete an uploaded file', + action: 'Delete a file', + }, + { + name: 'Get', + value: 'get', + description: 'Get a details of an uploaded file', + action: 'Get a file', + }, + { + name: 'Get Many', + value: 'getMany', + description: 'Get details of multiple uploaded files', + action: 'Get many files', + }, + { + name: 'Load', + value: 'load', + description: 'Load a file into a session', + action: 'Load a file', + }, + { + name: 'Upload', + value: 'upload', + description: 'Upload a file into a session', + action: 'Upload a file', + }, + ], + default: 'getMany', + }, + ...deleteFile.description, + ...get.description, + ...getMany.description, + ...load.description, + ...upload.description, +]; diff --git a/packages/nodes-base/nodes/Airtop/actions/file/delete.operation.ts b/packages/nodes-base/nodes/Airtop/actions/file/delete.operation.ts new file mode 100644 index 0000000000..c672c8acec --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/file/delete.operation.ts @@ -0,0 +1,40 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { ERROR_MESSAGES } from '../../constants'; +import { apiRequest } from '../../transport'; + +export const description: INodeProperties[] = [ + { + displayName: 'File ID', + name: 'fileId', + type: 'string', + default: '', + required: true, + description: 'ID of the file to delete', + displayOptions: { + show: { + resource: ['file'], + operation: ['deleteFile'], + }, + }, + }, +]; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const fileId = this.getNodeParameter('fileId', index, '') as string; + + if (!fileId) { + throw new NodeOperationError( + this.getNode(), + ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'File ID'), + ); + } + + await apiRequest.call(this, 'DELETE', `/files/${fileId}`); + + return this.helpers.returnJsonArray({ data: { message: 'File deleted successfully' } }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/file/get.operation.ts b/packages/nodes-base/nodes/Airtop/actions/file/get.operation.ts new file mode 100644 index 0000000000..bbd3734a05 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/file/get.operation.ts @@ -0,0 +1,76 @@ +import { NodeOperationError } from 'n8n-workflow'; +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { ERROR_MESSAGES } from '../../constants'; +import { apiRequest } from '../../transport'; +import type { IAirtopResponseWithFiles } from '../../transport/types'; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['get'], + }, +}; + +export const description: INodeProperties[] = [ + { + displayName: 'File ID', + name: 'fileId', + type: 'string', + default: '', + required: true, + description: 'ID of the file to retrieve', + displayOptions, + }, + { + displayName: 'Output Binary File', + name: 'outputBinaryFile', + type: 'boolean', + default: false, + description: 'Whether to output the file in binary format if the file is ready for download', + displayOptions, + }, +]; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const fileId = this.getNodeParameter('fileId', index, '') as string; + const outputBinaryFile = this.getNodeParameter('outputBinaryFile', index, false); + + if (!fileId) { + throw new NodeOperationError( + this.getNode(), + ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'File ID'), + ); + } + + const response = (await apiRequest.call( + this, + 'GET', + `/files/${fileId}`, + )) as IAirtopResponseWithFiles; + + const { fileName = '', downloadUrl = '', status = '' } = response?.data ?? {}; + + // Handle binary file output + if (outputBinaryFile && downloadUrl && status === 'available') { + const buffer = (await this.helpers.httpRequest({ + url: downloadUrl, + json: false, + encoding: 'arraybuffer', + })) as Buffer; + const file = await this.helpers.prepareBinaryData(buffer, fileName); + return [ + { + json: { + ...response, + }, + binary: { data: file }, + }, + ]; + } + + return this.helpers.returnJsonArray({ ...response }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/file/getMany.operation.ts b/packages/nodes-base/nodes/Airtop/actions/file/getMany.operation.ts new file mode 100644 index 0000000000..680feaffd2 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/file/getMany.operation.ts @@ -0,0 +1,97 @@ +import { + type IExecuteFunctions, + type INodeExecutionData, + type INodeProperties, +} from 'n8n-workflow'; + +import { requestAllFiles } from './helpers'; +import { wrapData } from '../../../../utils/utilities'; +import { apiRequest } from '../../transport'; +import type { IAirtopResponse } from '../../transport/types'; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['getMany'], + }, +}; + +export const description: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['file'], + operation: ['getMany'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 10, + description: 'Max number of results to return', + }, + { + displayName: 'Session IDs', + name: 'sessionIds', + type: 'string', + default: '', + description: + 'Comma-separated list of Session IDs to filter files by. When empty, all files from all sessions will be returned.', + placeholder: 'e.g. 6aac6f73-bd89-4a76-ab32-5a6c422e8b0b, a13c6f73-bd89-4a76-ab32-5a6c422e8224', + displayOptions, + }, + { + displayName: 'Output Files in Single Item', + name: 'outputSingleItem', + type: 'boolean', + default: true, + description: + 'Whether to output one item containing all files or output each file as a separate item', + displayOptions, + }, +]; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const returnAll = this.getNodeParameter('returnAll', index, false) as boolean; + const limit = this.getNodeParameter('limit', index, 10); + const sessionIds = this.getNodeParameter('sessionIds', index, '') as string; + const outputSingleItem = this.getNodeParameter('outputSingleItem', index, true) as boolean; + + const endpoint = '/files'; + let files: IAirtopResponse[] = []; + + const responseData = returnAll + ? await requestAllFiles.call(this, sessionIds) + : await apiRequest.call(this, 'GET', endpoint, {}, { sessionIds, limit }); + + if (responseData.data?.files && Array.isArray(responseData.data?.files)) { + files = responseData.data.files; + } + + /** + * Returns the files in one of two formats: + * - A single JSON item containing an array of all files (when outputSingleItem = true) + * - Multiple JSON items, one per file + * Data structure reference: https://docs.n8n.io/courses/level-two/chapter-1/#data-structure-of-n8n + */ + if (outputSingleItem) { + return this.helpers.returnJsonArray({ ...responseData }); + } + return wrapData(files); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/file/helpers.ts b/packages/nodes-base/nodes/Airtop/actions/file/helpers.ts new file mode 100644 index 0000000000..6989fa9036 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/file/helpers.ts @@ -0,0 +1,301 @@ +import type { IExecuteFunctions } from 'n8n-workflow'; +import { jsonParse, NodeApiError } from 'n8n-workflow'; +import type { Stream } from 'stream'; + +import { BASE_URL, ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants'; +import { apiRequest } from '../../transport'; +import type { IAirtopResponseWithFiles, IAirtopServerEvent } from '../../transport/types'; + +/** + * Fetches all files from the Airtop API using pagination + * @param this - The execution context providing access to n8n functionality + * @param sessionIds - Comma-separated string of session IDs to filter files by + * @returns Promise resolving to a response object containing the complete array of files + */ +export async function requestAllFiles( + this: IExecuteFunctions, + sessionIds: string, +): Promise { + const endpoint = '/files'; + let hasMore = true; + let currentOffset = 0; + const limit = 100; + const files: IAirtopResponseWithFiles['data']['files'] = []; + let responseData: IAirtopResponseWithFiles; + + while (hasMore) { + // request files + responseData = (await apiRequest.call( + this, + 'GET', + endpoint, + {}, + { offset: currentOffset, limit, sessionIds }, + )) as IAirtopResponseWithFiles; + // add files to the array + if (responseData.data?.files && Array.isArray(responseData.data?.files)) { + files.push(...responseData.data.files); + } + // check if there are more files + hasMore = Boolean(responseData.data?.pagination?.hasMore); + currentOffset += limit; + } + + return { + data: { + files, + pagination: { + hasMore, + }, + }, + }; +} + +/** + * Polls the Airtop API until a file reaches "available" status or times out + * @param this - The execution context providing access to n8n functionality + * @param fileId - The unique identifier of the file to poll + * @param timeout - Maximum time in milliseconds to wait before failing (defaults to OPERATION_TIMEOUT) + * @param intervalSeconds - Time in seconds to wait between polling attempts (defaults to 1) + * @returns Promise resolving to the file ID when the file is available + * @throws NodeApiError if the operation times out or API request fails + */ +export async function pollFileUntilAvailable( + this: IExecuteFunctions, + fileId: string, + timeout = OPERATION_TIMEOUT, + intervalSeconds = 1, +): Promise { + let fileStatus = ''; + const startTime = Date.now(); + + while (fileStatus !== 'available') { + const elapsedTime = Date.now() - startTime; + if (elapsedTime >= timeout) { + throw new NodeApiError(this.getNode(), { + message: ERROR_MESSAGES.TIMEOUT_REACHED, + code: 500, + }); + } + + const response = await apiRequest.call(this, 'GET', `/files/${fileId}`); + fileStatus = response.data?.status as string; + + // Wait before the next polling attempt + await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000)); + } + + return fileId; +} + +/** + * Creates a file entry in Airtop, uploads the file content, and waits until processing completes + * @param this - The execution context providing access to n8n functionality + * @param fileName - Name to assign to the uploaded file + * @param fileBuffer - Buffer containing the binary file data to upload + * @param fileType - Classification of the file in Airtop (e.g., 'customer_upload') + * @param pollingFunction - Function to use for checking file availability (defaults to pollFileUntilAvailable) + * @returns Promise resolving to the file ID once upload is complete and file is available + * @throws NodeApiError if file creation, upload, or polling fails + */ +export async function createAndUploadFile( + this: IExecuteFunctions, + fileName: string, + fileBuffer: Buffer, + fileType: string, + pollingFunction = pollFileUntilAvailable, +): Promise { + // Create file entry + const createResponse = await apiRequest.call(this, 'POST', '/files', { fileName, fileType }); + + const fileId = createResponse.data?.id; + const uploadUrl = createResponse.data?.uploadUrl as string; + + if (!fileId || !uploadUrl) { + throw new NodeApiError(this.getNode(), { + message: 'Failed to create file entry: missing file ID or upload URL', + code: 500, + }); + } + + // Upload the file + await this.helpers.httpRequest({ + method: 'PUT', + url: uploadUrl, + body: fileBuffer, + headers: { + 'Content-Type': 'application/octet-stream', + }, + }); + + // Poll until the file is available + return await pollingFunction.call(this, fileId as string); +} + +function parseEvent(eventText: string): IAirtopServerEvent | null { + const dataLine = eventText.split('\n').find((line) => line.startsWith('data:')); + if (!dataLine) { + return null; + } + const jsonStr = dataLine.replace('data: ', '').trim(); + return jsonParse(jsonStr, { + errorMessage: 'Failed to parse server event', + }); +} + +function isFileAvailable(event: IAirtopServerEvent, fileId: string): boolean { + return ( + event.event === 'file_upload_status' && event.fileId === fileId && event.status === 'available' + ); +} + +/** + * Waits for a file to be ready in a session by monitoring session events + * @param this - The execution context providing access to n8n functionality + * @param sessionId - ID of the session to monitor for file events + * @param timeout - Maximum time in milliseconds to wait before failing (defaults to OPERATION_TIMEOUT) + * @returns Promise that resolves when a file in the session becomes available + * @throws NodeApiError if the timeout is reached before a file becomes available + */ +export async function waitForFileInSession( + this: IExecuteFunctions, + sessionId: string, + fileId: string, + timeout = OPERATION_TIMEOUT, +): Promise { + const url = `${BASE_URL}/sessions/${sessionId}/events?all=true`; + + const fileReadyPromise = new Promise(async (resolve, reject) => { + const stream = (await this.helpers.httpRequestWithAuthentication.call(this, 'airtopApi', { + method: 'GET', + url, + encoding: 'stream', + })) as Stream; + + const close = () => { + resolve(); + stream.removeAllListeners(); + }; + + const onError = (errorMessage: string) => { + const error = new NodeApiError(this.getNode(), { + message: errorMessage, + description: 'Failed to upload file', + code: 500, + }); + reject(error); + stream.removeAllListeners(); + }; + + stream.on('data', (data: Uint8Array) => { + const event = parseEvent(data.toString()); + if (!event) { + return; + } + // handle error + if (event?.eventData?.error) { + onError(event.eventData.error); + return; + } + // handle file available + if (isFileAvailable(event, fileId)) { + close(); + } + }); + }); + + const timeoutPromise = new Promise((_resolve, reject) => { + setTimeout( + () => + reject( + new NodeApiError(this.getNode(), { + message: ERROR_MESSAGES.TIMEOUT_REACHED, + code: 500, + }), + ), + timeout, + ); + }); + + await Promise.race([fileReadyPromise, timeoutPromise]); +} + +/** + * Associates a file with a session and waits until the file is ready for use + * @param this - The execution context providing access to n8n functionality + * @param fileId - ID of the file to associate with the session + * @param sessionId - ID of the session to add the file to + * @param pollingFunction - Function to use for checking file availability in session (defaults to waitForFileInSession) + * @returns Promise that resolves when the file is ready for use in the session + */ +export async function pushFileToSession( + this: IExecuteFunctions, + fileId: string, + sessionId: string, + pollingFunction = waitForFileInSession, +): Promise { + // Push file into session + await apiRequest.call(this, 'POST', `/files/${fileId}/push`, { sessionIds: [sessionId] }); + await pollingFunction.call(this, sessionId, fileId); +} + +/** + * Activates a file upload input in a specific window within a session + * @param this - The execution context providing access to n8n functionality + * @param fileId - ID of the file to use for the input + * @param windowId - ID of the window where the file input will be triggered + * @param sessionId - ID of the session containing the window + * @returns Promise that resolves when the file input has been triggered + */ +export async function triggerFileInput( + this: IExecuteFunctions, + fileId: string, + windowId: string, + sessionId: string, + elementDescription = '', +): Promise { + await apiRequest.call(this, 'POST', `/sessions/${sessionId}/windows/${windowId}/file-input`, { + fileId, + ...(elementDescription ? { elementDescription } : {}), + }); +} + +/** + * Creates a file Buffer from either a URL or binary data + * This function supports two source types: + * - URL: Downloads the file from the specified URL and returns it as a Buffer + * - Binary: Retrieves binary data from the workflow's binary data storage + * + * @param this - The execution context providing access to n8n functionality + * @param source - Source type, either 'url' or 'binary' + * @param value - Either a URL string or binary data property name depending on source type + * @param itemIndex - Index of the workflow item to get binary data from (when source is 'binary') + * @returns Promise resolving to a Buffer containing the file data + * @throws NodeApiError if the source type is unsupported or retrieval fails + */ +export async function createFileBuffer( + this: IExecuteFunctions, + source: string, + value: string, + itemIndex: number, +): Promise { + if (source === 'url') { + const buffer = (await this.helpers.httpRequest({ + url: value, + json: false, + encoding: 'arraybuffer', + })) as Buffer; + + return buffer; + } + + if (source === 'binary') { + const binaryData = await this.helpers.getBinaryDataBuffer(itemIndex, value); + return binaryData; + } + + throw new NodeApiError(this.getNode(), { + message: `Unsupported source type: ${source}. Please use 'url' or 'binary'`, + code: 500, + }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/file/load.operation.ts b/packages/nodes-base/nodes/Airtop/actions/file/load.operation.ts new file mode 100644 index 0000000000..898b472f1d --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/file/load.operation.ts @@ -0,0 +1,65 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { pushFileToSession, triggerFileInput } from './helpers'; +import { sessionIdField, windowIdField, elementDescriptionField } from '../common/fields'; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['load'], + }, +}; + +export const description: INodeProperties[] = [ + { + ...sessionIdField, + description: 'The session ID to load the file into', + displayOptions, + }, + { + ...windowIdField, + description: 'The window ID to trigger the file input in', + displayOptions, + }, + { + displayName: 'File ID', + name: 'fileId', + type: 'string', + default: '', + required: true, + description: 'ID of the file to load into the session', + displayOptions, + }, + { + ...elementDescriptionField, + description: 'Optional description of the file input to interact with', + placeholder: 'e.g. the file upload selection box', + displayOptions, + }, +]; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const fileId = this.getNodeParameter('fileId', index, '') as string; + const sessionId = this.getNodeParameter('sessionId', index, '') as string; + const windowId = this.getNodeParameter('windowId', index, '') as string; + const elementDescription = this.getNodeParameter('elementDescription', index, '') as string; + + try { + await pushFileToSession.call(this, fileId, sessionId); + await triggerFileInput.call(this, fileId, windowId, sessionId, elementDescription); + + return this.helpers.returnJsonArray({ + sessionId, + windowId, + data: { + message: 'File loaded successfully', + }, + }); + } catch (error) { + throw new NodeOperationError(this.getNode(), error as Error); + } +} diff --git a/packages/nodes-base/nodes/Airtop/actions/file/upload.operation.ts b/packages/nodes-base/nodes/Airtop/actions/file/upload.operation.ts new file mode 100644 index 0000000000..8949503936 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/file/upload.operation.ts @@ -0,0 +1,177 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { + createAndUploadFile, + pushFileToSession, + triggerFileInput, + createFileBuffer, +} from './helpers'; +import { validateRequiredStringField } from '../../GenericFunctions'; +import { sessionIdField, windowIdField, elementDescriptionField } from '../common/fields'; + +const displayOptions = { + show: { + resource: ['file'], + operation: ['upload'], + }, +}; + +export const description: INodeProperties[] = [ + { + ...sessionIdField, + description: 'The session ID to load the file into', + displayOptions, + }, + { + ...windowIdField, + description: 'The window ID to trigger the file input in', + displayOptions, + }, + { + displayName: 'File Name', + name: 'fileName', + type: 'string', + default: '', + required: true, + description: + 'Name for the file to upload. For a session, all files loaded should have unique names.', + displayOptions, + }, + { + displayName: 'File Type', + name: 'fileType', + type: 'options', + options: [ + { + name: 'Browser Download', + value: 'browser_download', + }, + { + name: 'Screenshot', + value: 'screenshot', + }, + { + name: 'Video', + value: 'video', + }, + { + name: 'Customer Upload', + value: 'customer_upload', + }, + ], + default: 'customer_upload', + description: "Choose the type of file to upload. Defaults to 'Customer Upload'.", + displayOptions, + }, + { + displayName: 'Source', + name: 'source', + type: 'options', + options: [ + { + name: 'URL', + value: 'url', + }, + { + name: 'Binary', + value: 'binary', + }, + ], + default: 'url', + description: 'Source of the file to upload', + displayOptions, + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + source: ['binary'], + ...displayOptions.show, + }, + }, + description: 'Name of the binary property containing the file data', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + source: ['url'], + ...displayOptions.show, + }, + }, + description: 'URL from where to fetch the file to upload', + }, + { + displayName: 'Trigger File Input', + name: 'triggerFileInputParameter', + type: 'boolean', + default: true, + description: + 'Whether to automatically trigger the file input dialog in the current window. If disabled, the file will only be uploaded to the session without opening the file input dialog.', + displayOptions, + }, + { + ...elementDescriptionField, + description: 'Optional description of the file input to interact with', + placeholder: 'e.g. the file upload selection box', + displayOptions: { + show: { + triggerFileInputParameter: [true], + ...displayOptions.show, + }, + }, + }, +]; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const sessionId = validateRequiredStringField.call(this, index, 'sessionId', 'Session ID'); + const windowId = validateRequiredStringField.call(this, index, 'windowId', 'Window ID'); + const fileName = this.getNodeParameter('fileName', index, '') as string; + const fileType = this.getNodeParameter('fileType', index, 'customer_upload') as string; + const source = this.getNodeParameter('source', index, 'url') as string; + const url = this.getNodeParameter('url', index, '') as string; + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', index, ''); + const triggerFileInputParameter = this.getNodeParameter( + 'triggerFileInputParameter', + index, + true, + ) as boolean; + const elementDescription = this.getNodeParameter('elementDescription', index, '') as string; + + // Get the file content based on source type + const fileValue = source === 'url' ? url : binaryPropertyName; + + try { + const fileBuffer = await createFileBuffer.call(this, source, fileValue, index); + const fileId = await createAndUploadFile.call(this, fileName, fileBuffer, fileType); + // Push file to session + await pushFileToSession.call(this, fileId, sessionId); + + if (triggerFileInputParameter) { + await triggerFileInput.call(this, fileId, windowId, sessionId, elementDescription); + } + + return this.helpers.returnJsonArray({ + sessionId, + windowId, + data: { + fileId, + message: 'File uploaded successfully', + }, + }); + } catch (error) { + throw new NodeOperationError(this.getNode(), error as Error); + } +} diff --git a/packages/nodes-base/nodes/Airtop/actions/interaction/Interaction.resource.ts b/packages/nodes-base/nodes/Airtop/actions/interaction/Interaction.resource.ts index 2a0ecda3da..2a51abc5ce 100644 --- a/packages/nodes-base/nodes/Airtop/actions/interaction/Interaction.resource.ts +++ b/packages/nodes-base/nodes/Airtop/actions/interaction/Interaction.resource.ts @@ -1,10 +1,12 @@ import type { INodeProperties } from 'n8n-workflow'; import * as click from './click.operation'; +import * as fill from './fill.operation'; import * as hover from './hover.operation'; +import * as scroll from './scroll.operation'; import * as type from './type.operation'; import { sessionIdField, windowIdField } from '../common/fields'; -export { click, hover, type }; +export { click, fill, hover, scroll, type }; export const description: INodeProperties[] = [ { @@ -24,12 +26,24 @@ export const description: INodeProperties[] = [ description: 'Execute a click on an element given a description', action: 'Click an element', }, + { + name: 'Fill Form', + value: 'fill', + description: 'Fill a form with the provided information', + action: 'Fill form', + }, { name: 'Hover on an Element', value: 'hover', description: 'Execute a hover action on an element given a description', action: 'Hover on an element', }, + { + name: 'Scroll', + value: 'scroll', + description: 'Execute a scroll action on the page', + action: 'Scroll on page', + }, { name: 'Type', value: 'type', @@ -56,7 +70,9 @@ export const description: INodeProperties[] = [ }, }, ...click.description, + ...fill.description, ...hover.description, + ...scroll.description, ...type.description, { displayName: 'Additional Fields', @@ -67,6 +83,7 @@ export const description: INodeProperties[] = [ displayOptions: { show: { resource: ['interaction'], + operation: ['click', 'hover', 'type', 'scroll'], }, }, options: [ diff --git a/packages/nodes-base/nodes/Airtop/actions/interaction/fill.operation.ts b/packages/nodes-base/nodes/Airtop/actions/interaction/fill.operation.ts new file mode 100644 index 0000000000..dad2f6e655 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/interaction/fill.operation.ts @@ -0,0 +1,88 @@ +import { + type IExecuteFunctions, + type INodeExecutionData, + type INodeProperties, + NodeApiError, +} from 'n8n-workflow'; + +import { ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants'; +import { + validateRequiredStringField, + validateSessionAndWindowId, + validateAirtopApiResponse, +} from '../../GenericFunctions'; +import { apiRequest } from '../../transport'; +import type { IAirtopResponse } from '../../transport/types'; + +export const description: INodeProperties[] = [ + { + displayName: 'Form Data', + name: 'formData', + type: 'string', + typeOptions: { + rows: 4, + }, + required: true, + default: '', + displayOptions: { + show: { + resource: ['interaction'], + operation: ['fill'], + }, + }, + description: 'The information to fill into the form written in natural language', + placeholder: 'e.g. "Name: John Doe, Email: john.doe@example.com"', + }, +]; + +export async function execute( + this: IExecuteFunctions, + index: number, + timeout = OPERATION_TIMEOUT, +): Promise { + const { sessionId, windowId } = validateSessionAndWindowId.call(this, index); + const formData = validateRequiredStringField.call(this, index, 'formData', 'Form Data'); + + // run automation + const asyncAutomationResponse = await apiRequest.call( + this, + 'POST', + `/async/sessions/${sessionId}/windows/${windowId}/execute-automation`, + { + automationId: 'auto', + parameters: { + customData: formData, + }, + }, + ); + + const reqId = asyncAutomationResponse.requestId as string; + + // Poll status every second until it's completed or timeout is reached + const startTime = Date.now(); + let automationStatusResponse: IAirtopResponse; + + while (true) { + automationStatusResponse = await apiRequest.call(this, 'GET', `/requests/${reqId}/status`); + const status = automationStatusResponse?.status ?? ''; + + validateAirtopApiResponse(this.getNode(), automationStatusResponse); + + if (status === 'completed' || status === 'error') { + break; + } + + const elapsedTime = Date.now() - startTime; + if (elapsedTime >= timeout) { + throw new NodeApiError(this.getNode(), { + message: ERROR_MESSAGES.TIMEOUT_REACHED, + code: 500, + }); + } + + // Wait one second + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + return this.helpers.returnJsonArray({ sessionId, windowId, ...automationStatusResponse }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/interaction/scroll.operation.ts b/packages/nodes-base/nodes/Airtop/actions/interaction/scroll.operation.ts new file mode 100644 index 0000000000..f70d010517 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/interaction/scroll.operation.ts @@ -0,0 +1,224 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; + +import { constructInteractionRequest } from './helpers'; +import { + validateRequiredStringField, + validateSessionAndWindowId, + validateAirtopApiResponse, + validateScrollByAmount, + validateScrollingMode, +} from '../../GenericFunctions'; +import { apiRequest } from '../../transport'; + +export const description: INodeProperties[] = [ + { + displayName: 'Scroll Mode', + name: 'scrollingMode', + type: 'options', + description: 'Choose the mode of scrolling', + options: [ + { + name: 'Automatic', + value: 'automatic', + description: 'Describe with natural language the element to scroll to', + }, + { + name: 'Manual', + value: 'manual', + description: 'Define the direction and amount to scroll by', + }, + ], + default: 'automatic', + required: true, + displayOptions: { + show: { + resource: ['interaction'], + operation: ['scroll'], + }, + }, + }, + { + displayName: 'Element Description', + default: '', + description: 'A natural language description of the element to scroll to', + name: 'scrollToElement', + type: 'string', + placeholder: 'e.g. the page section titled "Contact Us"', + required: true, + displayOptions: { + show: { + resource: ['interaction'], + operation: ['scroll'], + scrollingMode: ['automatic'], + }, + }, + }, + { + displayName: 'Scroll To Page Edges', + name: 'scrollToEdge', + type: 'fixedCollection', + default: {}, + placeholder: 'Add Edge Direction', + description: + "The direction to scroll to. When 'Scroll By' is defined, 'Scroll To Edge' action will be executed first, then 'Scroll By' action.", + displayOptions: { + show: { + resource: ['interaction'], + operation: ['scroll'], + scrollingMode: ['manual'], + }, + }, + options: [ + { + displayName: 'Page Edges', + name: 'edgeValues', + values: [ + { + displayName: 'Vertically', + name: 'yAxis', + type: 'options', + default: '', + options: [ + { + name: 'Empty', + value: '', + }, + { + name: 'Top', + value: 'top', + }, + { + name: 'Bottom', + value: 'bottom', + }, + ], + }, + { + displayName: 'Horizontally', + name: 'xAxis', + type: 'options', + default: '', + options: [ + { + name: 'Empty', + value: '', + }, + { + name: 'Left', + value: 'left', + }, + { + name: 'Right', + value: 'right', + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'Scroll By', + name: 'scrollBy', + type: 'fixedCollection', + default: {}, + description: + "The amount to scroll by. When 'Scroll To Edge' is defined, 'Scroll By' action will be executed after 'Scroll To Edge'.", + placeholder: 'Add Scroll Amount', + displayOptions: { + show: { + resource: ['interaction'], + operation: ['scroll'], + scrollingMode: ['manual'], + }, + }, + options: [ + { + name: 'scrollValues', + displayName: 'Scroll Values', + description: 'The amount in pixels or percentage to scroll by', + values: [ + { + displayName: 'Vertically', + name: 'yAxis', + type: 'string', + default: '', + placeholder: 'e.g. 200px, 50%, -100px', + }, + { + displayName: 'Horizontally', + name: 'xAxis', + type: 'string', + default: '', + placeholder: 'e.g. 50px, 10%, -200px', + }, + ], + }, + ], + }, + { + displayName: 'Scrollable Area', + name: 'scrollWithin', + type: 'string', + default: '', + description: 'Scroll within an element on the page', + placeholder: 'e.g. the left sidebar', + displayOptions: { + show: { + resource: ['interaction'], + operation: ['scroll'], + scrollingMode: ['automatic'], + }, + }, + }, +]; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + const { sessionId, windowId } = validateSessionAndWindowId.call(this, index); + + const scrollingMode = validateScrollingMode.call(this, index); + const isAutomatic = scrollingMode === 'automatic'; + + const scrollToElement = isAutomatic + ? validateRequiredStringField.call(this, index, 'scrollToElement', 'Element Description') + : ''; + + const scrollToEdge = this.getNodeParameter('scrollToEdge.edgeValues', index, {}) as { + xAxis?: string; + yAxis?: string; + }; + + const scrollBy = validateScrollByAmount.call(this, index, 'scrollBy.scrollValues'); + + const scrollWithin = this.getNodeParameter('scrollWithin', index, '') as string; + + const request: IDataObject = { + // when scrollingMode is 'Manual' + ...(!isAutomatic ? { scrollToEdge } : {}), + ...(!isAutomatic ? { scrollBy } : {}), + // when scrollingMode is 'Automatic' + ...(isAutomatic ? { scrollToElement } : {}), + ...(isAutomatic ? { scrollWithin } : {}), + }; + + const fullRequest = constructInteractionRequest.call(this, index, request); + + const response = await apiRequest.call( + this, + 'POST', + `/sessions/${sessionId}/windows/${windowId}/scroll`, + fullRequest, + ); + + 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 index 6175d22a56..15a822b822 100644 --- a/packages/nodes-base/nodes/Airtop/actions/node.type.ts +++ b/packages/nodes-base/nodes/Airtop/actions/node.type.ts @@ -4,7 +4,8 @@ type NodeMap = { session: 'create' | 'save' | 'terminate'; window: 'create' | 'close' | 'takeScreenshot' | 'load'; extraction: 'getPaginated' | 'query' | 'scrape'; - interaction: 'click' | 'hover' | 'type'; + interaction: 'click' | 'fill' | 'hover' | 'type'; + file: 'getMany' | 'get' | 'deleteFile' | 'upload' | 'load'; }; export type AirtopType = AllEntities; diff --git a/packages/nodes-base/nodes/Airtop/actions/router.ts b/packages/nodes-base/nodes/Airtop/actions/router.ts index 826001010f..5c3e0ce429 100644 --- a/packages/nodes-base/nodes/Airtop/actions/router.ts +++ b/packages/nodes-base/nodes/Airtop/actions/router.ts @@ -1,15 +1,20 @@ -import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; +import { cleanOutputForToolUse } from './common/output.utils'; import * as extraction from './extraction/Extraction.resource'; +import * as file from './file/File.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'; +import type { IAirtopNodeExecutionData } from '../transport/types'; export async function router(this: IExecuteFunctions): Promise { const operationResult: INodeExecutionData[] = []; - let responseData: IDataObject | IDataObject[] = []; + let responseData: IAirtopNodeExecutionData[] = []; + const nodeType = this.getNode().type; + const isCalledAsTool = nodeType.includes('airtopTool'); const items = this.getInputData(); const resource = this.getNodeParameter('resource', 0); @@ -35,6 +40,9 @@ export async function router(this: IExecuteFunctions): Promise ({ name, value })); + export const description: INodeProperties[] = [ { ...profileNameField, - displayOptions: { - show: { - resource: ['session'], - operation: ['create'], - }, - }, + displayOptions, }, { displayName: 'Save Profile', @@ -33,12 +37,7 @@ export const description: INodeProperties[] = [ default: false, description: 'Whether to automatically save the Airtop profile for this session upon termination', - displayOptions: { - show: { - resource: ['session'], - operation: ['create'], - }, - }, + displayOptions, }, { displayName: 'Idle Timeout', @@ -47,13 +46,11 @@ export const description: INodeProperties[] = [ default: 10, validateType: 'number', description: 'Minutes to wait before the session is terminated due to inactivity', - displayOptions: { - show: { - resource: ['session'], - operation: ['create'], - }, - }, + displayOptions, }, + /** + * Proxy Configuration + */ { displayName: 'Proxy', name: 'proxy', @@ -72,15 +69,43 @@ export const description: INodeProperties[] = [ description: 'Use Airtop-provided proxy', }, { - name: 'Custom', - value: 'custom', - description: 'Configure a custom proxy', + name: 'Proxy URL', + value: 'proxyUrl', + description: 'Use a proxy URL to configure the proxy', + }, + ], + displayOptions, + }, + { + displayName: 'Proxy Configuration', + name: 'proxyConfig', + type: 'collection', + default: { country: 'US', sticky: true }, + description: 'The Airtop-provided configuration to use for the proxy', + placeholder: 'Add Attribute', + options: [ + { + displayName: 'Country', + name: 'country', + type: 'options', + default: 'US', + description: + 'The country to use for the proxy. Not all countries are guaranteed to provide a proxy. Learn more here.', + options: countryOptions, + }, + { + displayName: 'Keep Same IP', + name: 'sticky', + type: 'boolean', + default: true, + description: + 'Whether to try to maintain the same IP address for the duration of the session. Airtop can guarantee that the same IP address will be available for up to a maximum of 30 minutes.', }, ], displayOptions: { show: { - resource: ['session'], - operation: ['create'], + ...displayOptions.show, + proxy: ['integrated'], }, }, }, @@ -90,39 +115,71 @@ export const description: INodeProperties[] = [ type: 'string', default: '', description: 'The URL of the proxy to use', + validateType: 'string', displayOptions: { show: { - proxy: ['custom'], + ...displayOptions.show, + proxy: ['proxyUrl'], }, }, }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions, + options: [ + { + displayName: 'Auto Solve Captchas', + name: 'solveCaptcha', + type: 'boolean', + default: false, + description: + 'Whether to automatically solve captcha challenges', + }, + { + displayName: 'Extension IDs', + name: 'extensionIds', + type: 'string', + default: '', + placeholder: 'e.g. extId1, extId2, ...', + description: + 'Comma-separated extension IDs from the Google Web Store to be loaded into the session. Learn more here.', + }, + ], + }, ]; 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 { proxy } = validateProxy.call(this, index); + const solveCaptcha = this.getNodeParameter( + 'additionalFields.solveCaptcha', + index, + false, + ) as boolean; + + const extensions = this.getNodeParameter('additionalFields.extensionIds', index, '') as string; + const extensionIds = extensions ? extensions.split(',').map((id) => id.trim()) : []; const body: IDataObject = { configuration: { profileName, timeoutMinutes, - proxy: proxyParam === 'custom' ? proxyUrl : Boolean(proxyParam === 'integrated'), + proxy, + solveCaptcha, + ...(extensionIds.length > 0 ? { extensionIds } : {}), }, }; - const response = await apiRequest.call(this, 'POST', url, body); - const sessionId = response.sessionId; - - // validate response - validateAirtopApiResponse(this.getNode(), response); + const { sessionId } = await createSession.call(this, body); if (saveProfileOnTermination) { await apiRequest.call( diff --git a/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts b/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts index 524b85071c..ceb922dc60 100644 --- a/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts +++ b/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts @@ -69,4 +69,5 @@ export const description: INodeProperties[] = [ }, ...create.description, ...load.description, + ...takeScreenshot.description, ]; diff --git a/packages/nodes-base/nodes/Airtop/actions/window/create.operation.ts b/packages/nodes-base/nodes/Airtop/actions/window/create.operation.ts index 1a13413a33..894f9cbdfa 100644 --- a/packages/nodes-base/nodes/Airtop/actions/window/create.operation.ts +++ b/packages/nodes-base/nodes/Airtop/actions/window/create.operation.ts @@ -110,7 +110,7 @@ export const description: INodeProperties[] = [ { name: 'Load', value: 'load', - description: "Wait until the page dom and it's assets have loaded", + description: 'Wait until the page dom and its assets have loaded', }, { name: 'DOM Content Loaded', diff --git a/packages/nodes-base/nodes/Airtop/actions/window/takeScreenshot.operation.ts b/packages/nodes-base/nodes/Airtop/actions/window/takeScreenshot.operation.ts index 5ad0294571..a2dfbb3d91 100644 --- a/packages/nodes-base/nodes/Airtop/actions/window/takeScreenshot.operation.ts +++ b/packages/nodes-base/nodes/Airtop/actions/window/takeScreenshot.operation.ts @@ -1,4 +1,9 @@ -import type { IExecuteFunctions, INodeExecutionData, IBinaryData } from 'n8n-workflow'; +import type { + IExecuteFunctions, + INodeExecutionData, + IBinaryData, + INodeProperties, +} from 'n8n-workflow'; import { validateSessionAndWindowId, @@ -7,12 +12,32 @@ import { } from '../../GenericFunctions'; import { apiRequest } from '../../transport'; +export const description: INodeProperties[] = [ + { + displayName: 'Output Binary Image', + description: 'Whether to output the image as a binary file instead of a base64 encoded string', + name: 'outputImageAsBinary', + type: 'boolean', + default: false, + displayOptions: { + show: { + resource: ['window'], + operation: ['takeScreenshot'], + }, + }, + }, +]; + export async function execute( this: IExecuteFunctions, index: number, ): Promise { const { sessionId, windowId } = validateSessionAndWindowId.call(this, index); + const outputImageAsBinary = this.getNodeParameter('outputImageAsBinary', index, false) as boolean; + let data: IBinaryData | undefined; // for storing the binary data + let image = ''; // for storing the base64 encoded image + const response = await apiRequest.call( this, 'POST', @@ -24,8 +49,12 @@ export async function execute( // 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'); + if (outputImageAsBinary) { + const buffer = convertScreenshotToBinary(response.meta.screenshots[0]); + data = await this.helpers.prepareBinaryData(buffer, 'screenshot.jpg', 'image/jpeg'); + } else { + image = response?.meta?.screenshots?.[0].dataUrl; + } } return [ @@ -33,7 +62,7 @@ export async function execute( json: { sessionId, windowId, - ...response, + image, }, ...(data ? { binary: { data } } : {}), }, diff --git a/packages/nodes-base/nodes/Airtop/constants.ts b/packages/nodes-base/nodes/Airtop/constants.ts index 6380c5869c..fd47739c5a 100644 --- a/packages/nodes-base/nodes/Airtop/constants.ts +++ b/packages/nodes-base/nodes/Airtop/constants.ts @@ -1,16 +1,54 @@ -export const BASE_URL = 'https://api.airtop.ai/api/v1'; -export const INTEGRATION_URL = 'https://portal-api.airtop.ai/integrations/v1/no-code'; +import { readFileSync } from 'fs'; +import type { n8n } from 'n8n-core'; +import { jsonParse } from 'n8n-workflow'; +import { join, resolve } from 'path'; +// Helper function to get n8n version that can be mocked in tests +export const getN8NVersion = (): string => { + if (process.env.N8N_VERSION) { + return process.env.N8N_VERSION; + } + + try { + const PACKAGE_DIR = resolve(__dirname, '../../../'); + const packageJsonPath = join(PACKAGE_DIR, 'package.json'); + const n8nPackageJson = jsonParse(readFileSync(packageJsonPath, 'utf8')); + return n8nPackageJson.version; + } catch (error) { + // Fallback version + return '0.0.0'; + } +}; + +export const N8N_VERSION = getN8NVersion(); + +export const BASE_URL = process.env.AIRTOP_BASE_URL ?? 'https://api.airtop.ai/api/v1'; +export const INTEGRATION_URL = + process.env.AIRTOP_INTEGRATION_URL ?? 'https://portal-api.airtop.ai/integrations/v1/no-code'; + +// Session operations export const DEFAULT_TIMEOUT_MINUTES = 10; export const MIN_TIMEOUT_MINUTES = 1; export const MAX_TIMEOUT_MINUTES = 10080; +export const SESSION_STATUS = { + INITIALIZING: 'initializing', + RUNNING: 'running', +} as const; +// Operations +export const OPERATION_TIMEOUT = 5 * 60 * 1000; // 5 mins + +// Scroll operation +export type TScrollingMode = 'manual' | 'automatic'; + +// Error messages 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`, + TIMEOUT_REACHED: 'Timeout reached while waiting for the operation to complete', 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", @@ -18,4 +56,7 @@ export const ERROR_MESSAGES = { 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')", -}; + SCROLL_BY_AMOUNT_INVALID: + "'Scroll By' amount must be a number and either a percentage or pixels (e.g. '100px' or '100%')", + SCROLL_MODE_INVALID: "Please fill any of the 'Scroll To Edge' or 'Scroll By' parameters", +} as const; diff --git a/packages/nodes-base/nodes/Airtop/countries.ts b/packages/nodes-base/nodes/Airtop/countries.ts new file mode 100644 index 0000000000..27fbb9c19a --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/countries.ts @@ -0,0 +1,998 @@ +export const COUNTRIES = [ + { + value: 'AF', + name: 'Afghanistan', + }, + { + value: 'AX', + name: 'Aland Islands', + }, + { + value: 'AL', + name: 'Albania', + }, + { + value: 'DZ', + name: 'Algeria', + }, + { + value: 'AS', + name: 'American Samoa', + }, + { + value: 'AD', + name: 'Andorra', + }, + { + value: 'AO', + name: 'Angola', + }, + { + value: 'AI', + name: 'Anguilla', + }, + { + value: 'AQ', + name: 'Antarctica', + }, + { + value: 'AG', + name: 'Antigua and Barbuda', + }, + { + value: 'AR', + name: 'Argentina', + }, + { + value: 'AM', + name: 'Armenia', + }, + { + value: 'AW', + name: 'Aruba', + }, + { + value: 'AU', + name: 'Australia', + }, + { + value: 'AT', + name: 'Austria', + }, + { + value: 'AZ', + name: 'Azerbaijan', + }, + { + value: 'BS', + name: 'Bahamas', + }, + { + value: 'BH', + name: 'Bahrain', + }, + { + value: 'BD', + name: 'Bangladesh', + }, + { + value: 'BB', + name: 'Barbados', + }, + { + value: 'BY', + name: 'Belarus', + }, + { + value: 'BE', + name: 'Belgium', + }, + { + value: 'BZ', + name: 'Belize', + }, + { + value: 'BJ', + name: 'Benin', + }, + { + value: 'BM', + name: 'Bermuda', + }, + { + value: 'BT', + name: 'Bhutan', + }, + { + value: 'BO', + name: 'Bolivia, Plurinational State Of', + }, + { + value: 'BQ', + name: 'Bonaire, Sint Eustatius and Saba', + }, + { + value: 'BA', + name: 'Bosnia and Herzegovina', + }, + { + value: 'BW', + name: 'Botswana', + }, + { + value: 'BV', + name: 'Bouvet Island', + }, + { + value: 'BR', + name: 'Brazil', + }, + { + value: 'IO', + name: 'British Indian Ocean Territory', + }, + { + value: 'BN', + name: 'Brunei Darussalam', + }, + { + value: 'BG', + name: 'Bulgaria', + }, + { + value: 'BF', + name: 'Burkina Faso', + }, + { + value: 'BI', + name: 'Burundi', + }, + { + value: 'CV', + name: 'Cabo Verde', + }, + { + value: 'KH', + name: 'Cambodia', + }, + { + value: 'CM', + name: 'Cameroon', + }, + { + value: 'CA', + name: 'Canada', + }, + { + value: 'KY', + name: 'Cayman Islands', + }, + { + value: 'CF', + name: 'Central African Republic', + }, + { + value: 'TD', + name: 'Chad', + }, + { + value: 'CL', + name: 'Chile', + }, + { + value: 'CN', + name: 'China', + }, + { + value: 'CX', + name: 'Christmas Island', + }, + { + value: 'CC', + name: 'Cocos (Keeling) Islands', + }, + { + value: 'CO', + name: 'Colombia', + }, + { + value: 'KM', + name: 'Comoros', + }, + { + value: 'CG', + name: 'Congo', + }, + { + value: 'CD', + name: 'Congo, Democratic Republic of The', + }, + { + value: 'CK', + name: 'Cook Islands', + }, + { + value: 'CR', + name: 'Costa Rica', + }, + { + value: 'CI', + name: "Cote d'Ivoire", + }, + { + value: 'HR', + name: 'Croatia', + }, + { + value: 'CU', + name: 'Cuba', + }, + { + value: 'CW', + name: 'Curaçao', + }, + { + value: 'CY', + name: 'Cyprus', + }, + { + value: 'CZ', + name: 'Czechia', + }, + { + value: 'DK', + name: 'Denmark', + }, + { + value: 'DJ', + name: 'Djibouti', + }, + { + value: 'DM', + name: 'Dominica', + }, + { + value: 'DO', + name: 'Dominican Republic', + }, + { + value: 'EC', + name: 'Ecuador', + }, + { + value: 'EG', + name: 'Egypt', + }, + { + value: 'SV', + name: 'El Salvador', + }, + { + value: 'GQ', + name: 'Equatorial Guinea', + }, + { + value: 'ER', + name: 'Eritrea', + }, + { + value: 'EE', + name: 'Estonia', + }, + { + value: 'SZ', + name: 'Eswatini', + }, + { + value: 'ET', + name: 'Ethiopia', + }, + { + value: 'FK', + name: 'Falkland Islands (Malvinas)', + }, + { + value: 'FO', + name: 'Faroe Islands', + }, + { + value: 'FJ', + name: 'Fiji', + }, + { + value: 'FI', + name: 'Finland', + }, + { + value: 'FR', + name: 'France', + }, + { + value: 'GF', + name: 'French Guiana', + }, + { + value: 'PF', + name: 'French Polynesia', + }, + { + value: 'TF', + name: 'French Southern Territories', + }, + { + value: 'GA', + name: 'Gabon', + }, + { + value: 'GM', + name: 'Gambia', + }, + { + value: 'GE', + name: 'Georgia', + }, + { + value: 'DE', + name: 'Germany', + }, + { + value: 'GH', + name: 'Ghana', + }, + { + value: 'GI', + name: 'Gibraltar', + }, + { + value: 'GR', + name: 'Greece', + }, + { + value: 'GL', + name: 'Greenland', + }, + { + value: 'GD', + name: 'Grenada', + }, + { + value: 'GP', + name: 'Guadeloupe', + }, + { + value: 'GU', + name: 'Guam', + }, + { + value: 'GT', + name: 'Guatemala', + }, + { + value: 'GG', + name: 'Guernsey', + }, + { + value: 'GN', + name: 'Guinea', + }, + { + value: 'GW', + name: 'Guinea-Bissau', + }, + { + value: 'GY', + name: 'Guyana', + }, + { + value: 'HT', + name: 'Haiti', + }, + { + value: 'HM', + name: 'Heard Island and McDonald Islands', + }, + { + value: 'VA', + name: 'Holy See', + }, + { + value: 'HN', + name: 'Honduras', + }, + { + value: 'HK', + name: 'Hong Kong', + }, + { + value: 'HU', + name: 'Hungary', + }, + { + value: 'IS', + name: 'Iceland', + }, + { + value: 'IN', + name: 'India', + }, + { + value: 'ID', + name: 'Indonesia', + }, + { + value: 'IR', + name: 'Iran, Islamic Republic Of', + }, + { + value: 'IQ', + name: 'Iraq', + }, + { + value: 'IE', + name: 'Ireland', + }, + { + value: 'IM', + name: 'Isle of Man', + }, + { + value: 'IL', + name: 'Israel', + }, + { + value: 'IT', + name: 'Italy', + }, + { + value: 'JM', + name: 'Jamaica', + }, + { + value: 'JP', + name: 'Japan', + }, + { + value: 'JE', + name: 'Jersey', + }, + { + value: 'JO', + name: 'Jordan', + }, + { + value: 'KZ', + name: 'Kazakhstan', + }, + { + value: 'KE', + name: 'Kenya', + }, + { + value: 'KI', + name: 'Kiribati', + }, + { + value: 'KP', + name: "Korea, Democratic People's Republic Of", + }, + { + value: 'KR', + name: 'Korea, Republic Of', + }, + { + value: 'KW', + name: 'Kuwait', + }, + { + value: 'KG', + name: 'Kyrgyzstan', + }, + { + value: 'LA', + name: "Lao People's Democratic Republic", + }, + { + value: 'LV', + name: 'Latvia', + }, + { + value: 'LB', + name: 'Lebanon', + }, + { + value: 'LS', + name: 'Lesotho', + }, + { + value: 'LR', + name: 'Liberia', + }, + { + value: 'LY', + name: 'Libya', + }, + { + value: 'LI', + name: 'Liechtenstein', + }, + { + value: 'LT', + name: 'Lithuania', + }, + { + value: 'LU', + name: 'Luxembourg', + }, + { + value: 'MO', + name: 'Macao', + }, + { + value: 'MG', + name: 'Madagascar', + }, + { + value: 'MW', + name: 'Malawi', + }, + { + value: 'MY', + name: 'Malaysia', + }, + { + value: 'MV', + name: 'Maldives', + }, + { + value: 'ML', + name: 'Mali', + }, + { + value: 'MT', + name: 'Malta', + }, + { + value: 'MH', + name: 'Marshall Islands', + }, + { + value: 'MQ', + name: 'Martinique', + }, + { + value: 'MR', + name: 'Mauritania', + }, + { + value: 'MU', + name: 'Mauritius', + }, + { + value: 'YT', + name: 'Mayotte', + }, + { + value: 'MX', + name: 'Mexico', + }, + { + value: 'FM', + name: 'Micronesia, Federated States Of', + }, + { + value: 'MD', + name: 'Moldova, Republic Of', + }, + { + value: 'MC', + name: 'Monaco', + }, + { + value: 'MN', + name: 'Mongolia', + }, + { + value: 'ME', + name: 'Montenegro', + }, + { + value: 'MS', + name: 'Montserrat', + }, + { + value: 'MA', + name: 'Morocco', + }, + { + value: 'MZ', + name: 'Mozambique', + }, + { + value: 'MM', + name: 'Myanmar', + }, + { + value: 'NA', + name: 'Namibia', + }, + { + value: 'NR', + name: 'Nauru', + }, + { + value: 'NP', + name: 'Nepal', + }, + { + value: 'NL', + name: 'Netherlands, Kingdom of The', + }, + { + value: 'NC', + name: 'New Caledonia', + }, + { + value: 'NZ', + name: 'New Zealand', + }, + { + value: 'NI', + name: 'Nicaragua', + }, + { + value: 'NE', + name: 'Niger', + }, + { + value: 'NG', + name: 'Nigeria', + }, + { + value: 'NU', + name: 'Niue', + }, + { + value: 'NF', + name: 'Norfolk Island', + }, + { + value: 'MK', + name: 'North Macedonia', + }, + { + value: 'MP', + name: 'Northern Mariana Islands', + }, + { + value: 'NO', + name: 'Norway', + }, + { + value: 'OM', + name: 'Oman', + }, + { + value: 'PK', + name: 'Pakistan', + }, + { + value: 'PW', + name: 'Palau', + }, + { + value: 'PS', + name: 'Palestine, State Of', + }, + { + value: 'PA', + name: 'Panama', + }, + { + value: 'PG', + name: 'Papua New Guinea', + }, + { + value: 'PY', + name: 'Paraguay', + }, + { + value: 'PE', + name: 'Peru', + }, + { + value: 'PH', + name: 'Philippines', + }, + { + value: 'PN', + name: 'Pitcairn', + }, + { + value: 'PL', + name: 'Poland', + }, + { + value: 'PT', + name: 'Portugal', + }, + { + value: 'PR', + name: 'Puerto Rico', + }, + { + value: 'QA', + name: 'Qatar', + }, + { + value: 'RE', + name: 'Réunion', + }, + { + value: 'RO', + name: 'Romania', + }, + { + value: 'RU', + name: 'Russian Federation', + }, + { + value: 'RW', + name: 'Rwanda', + }, + { + value: 'BL', + name: 'Saint Barthelemy', + }, + { + value: 'SH', + name: 'Saint Helena, Ascension and Tristan Da Cunha', + }, + { + value: 'KN', + name: 'Saint Kitts and Nevis', + }, + { + value: 'LC', + name: 'Saint Lucia', + }, + { + value: 'MF', + name: 'Saint Martin (French Part)', + }, + { + value: 'PM', + name: 'Saint Pierre and Miquelon', + }, + { + value: 'VC', + name: 'Saint Vincent and the Grenadines', + }, + { + value: 'WS', + name: 'Samoa', + }, + { + value: 'SM', + name: 'San Marino', + }, + { + value: 'ST', + name: 'Sao Tome and Principe', + }, + { + value: 'SA', + name: 'Saudi Arabia', + }, + { + value: 'SN', + name: 'Senegal', + }, + { + value: 'RS', + name: 'Serbia', + }, + { + value: 'SC', + name: 'Seychelles', + }, + { + value: 'SL', + name: 'Sierra Leone', + }, + { + value: 'SG', + name: 'Singapore', + }, + { + value: 'SX', + name: 'Sint Maarten (Dutch Part)', + }, + { + value: 'SK', + name: 'Slovakia', + }, + { + value: 'SI', + name: 'Slovenia', + }, + { + value: 'SB', + name: 'Solomon Islands', + }, + { + value: 'SO', + name: 'Somalia', + }, + { + value: 'ZA', + name: 'South Africa', + }, + { + value: 'GS', + name: 'South Georgia and the South Sandwich Islands', + }, + { + value: 'SS', + name: 'South Sudan', + }, + { + value: 'ES', + name: 'Spain', + }, + { + value: 'LK', + name: 'Sri Lanka', + }, + { + value: 'SD', + name: 'Sudan', + }, + { + value: 'SR', + name: 'Suriname', + }, + { + value: 'SJ', + name: 'Svalbard and Jan Mayen', + }, + { + value: 'SE', + name: 'Sweden', + }, + { + value: 'CH', + name: 'Switzerland', + }, + { + value: 'SY', + name: 'Syrian Arab Republic', + }, + { + value: 'TW', + name: 'Taiwan, Province of China', + }, + { + value: 'TJ', + name: 'Tajikistan', + }, + { + value: 'TZ', + name: 'Tanzania, United Republic Of', + }, + { + value: 'TH', + name: 'Thailand', + }, + { + value: 'TL', + name: 'Timor-Leste', + }, + { + value: 'TG', + name: 'Togo', + }, + { + value: 'TK', + name: 'Tokelau', + }, + { + value: 'TO', + name: 'Tonga', + }, + { + value: 'TT', + name: 'Trinidad and Tobago', + }, + { + value: 'TN', + name: 'Tunisia', + }, + { + value: 'TR', + name: 'Turkey', + }, + { + value: 'TM', + name: 'Turkmenistan', + }, + { + value: 'TC', + name: 'Turks and Caicos Islands', + }, + { + value: 'TV', + name: 'Tuvalu', + }, + { + value: 'UG', + name: 'Uganda', + }, + { + value: 'UA', + name: 'Ukraine', + }, + { + value: 'AE', + name: 'United Arab Emirates', + }, + { + value: 'GB', + name: 'United Kingdom of Great Britain and Northern Ireland', + }, + { + value: 'UM', + name: 'United States Minor Outlying Islands', + }, + { + value: 'US', + name: 'United States of America', + }, + { + value: 'UY', + name: 'Uruguay', + }, + { + value: 'UZ', + name: 'Uzbekistan', + }, + { + value: 'VU', + name: 'Vanuatu', + }, + { + value: 'VE', + name: 'Venezuela, Bolivarian Republic Of', + }, + { + value: 'VN', + name: 'Viet Nam', + }, + { + value: 'VG', + name: 'Virgin Islands (British)', + }, + { + value: 'VI', + name: 'Virgin Islands (U.S.)', + }, + { + value: 'WF', + name: 'Wallis and Futuna', + }, + { + value: 'EH', + name: 'Western Sahara', + }, + { + value: 'YE', + name: 'Yemen', + }, + { + value: 'ZM', + name: 'Zambia', + }, + { + value: 'ZW', + name: 'Zimbabwe', + }, +] as const; 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 index f3a0bd3f3b..a18a4e90c1 100644 --- a/packages/nodes-base/nodes/Airtop/test/node/extraction/query.test.ts +++ b/packages/nodes-base/nodes/Airtop/test/node/extraction/query.test.ts @@ -86,7 +86,11 @@ describe('Test Airtop, query page operation', () => { '/sessions/test-session-123/windows/win-123/page-query', { prompt: 'How many products are on the page and what is their price range?', - configuration: {}, + configuration: { + experimental: { + includeVisualAnalysis: 'disabled', + }, + }, }, ); @@ -122,6 +126,9 @@ describe('Test Airtop, query page operation', () => { prompt: 'How many products are on the page and what is their price range?', configuration: { outputSchema: mockJsonSchema, + experimental: { + includeVisualAnalysis: 'disabled', + }, }, }, ); @@ -157,7 +164,11 @@ describe('Test Airtop, query page operation', () => { '/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: {}, + configuration: { + experimental: { + includeVisualAnalysis: 'disabled', + }, + }, }, ); @@ -193,4 +204,80 @@ describe('Test Airtop, query page operation', () => { ERROR_MESSAGES.WINDOW_ID_REQUIRED, ); }); + + it("should query the page with 'includeVisualAnalysis' enabled", async () => { + const nodeParameters = { + ...baseNodeParameters, + prompt: 'List the colors of the products on the page', + additionalFields: { + includeVisualAnalysis: true, + }, + }; + + 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: 'List the colors of the products on the page', + configuration: { + experimental: { + includeVisualAnalysis: 'enabled', + }, + }, + }, + ); + + expect(result).toEqual([ + { + json: { + sessionId: 'test-session-123', + windowId: 'win-123', + data: mockResponse.data, + }, + }, + ]); + }); + + it("should query the page with 'includeVisualAnalysis' disabled", async () => { + const nodeParameters = { + ...baseNodeParameters, + prompt: 'How many products are on the page and what is their price range?', + additionalFields: { + includeVisualAnalysis: false, + }, + }; + + 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: { + experimental: { + includeVisualAnalysis: 'disabled', + }, + }, + }, + ); + + expect(result).toEqual([ + { + json: { + sessionId: 'test-session-123', + windowId: 'win-123', + data: mockResponse.data, + }, + }, + ]); + }); }); diff --git a/packages/nodes-base/nodes/Airtop/test/node/file/deleteFile.test.ts b/packages/nodes-base/nodes/Airtop/test/node/file/deleteFile.test.ts new file mode 100644 index 0000000000..eb57c20f72 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/file/deleteFile.test.ts @@ -0,0 +1,55 @@ +import * as deleteFile from '../../../actions/file/delete.operation'; +import { ERROR_MESSAGES } from '../../../constants'; +import * as transport from '../../../transport'; +import { createMockExecuteFunction } from '../helpers'; + +const baseNodeParameters = { + resource: 'file', + operation: 'deleteFile', + sessionId: 'test-session-123', + fileId: 'file-123', +}; + +jest.mock('../../../transport', () => { + const originalModule = jest.requireActual('../../../transport'); + return { + ...originalModule, + apiRequest: jest.fn().mockResolvedValue({}), + }; +}); + +describe('Test Airtop, delete file operation', () => { + afterAll(() => { + jest.unmock('../../../transport'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should delete file successfully', async () => { + const result = await deleteFile.execute.call(createMockExecuteFunction(baseNodeParameters), 0); + + expect(transport.apiRequest).toHaveBeenCalledWith('DELETE', '/files/file-123'); + expect(result).toEqual([ + { + json: { + data: { + message: 'File deleted successfully', + }, + }, + }, + ]); + }); + + it('should throw error when fileId is empty', async () => { + const nodeParameters = { + ...baseNodeParameters, + fileId: '', + }; + + await expect( + deleteFile.execute.call(createMockExecuteFunction(nodeParameters), 0), + ).rejects.toThrow(ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'File ID')); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/file/get.test.ts b/packages/nodes-base/nodes/Airtop/test/node/file/get.test.ts new file mode 100644 index 0000000000..ac1f15be05 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/file/get.test.ts @@ -0,0 +1,105 @@ +import * as get from '../../../actions/file/get.operation'; +import { ERROR_MESSAGES } from '../../../constants'; +import * as transport from '../../../transport'; +import { createMockExecuteFunction } from '../helpers'; + +const baseNodeParameters = { + resource: 'file', + operation: 'get', + sessionId: 'test-session-123', + fileId: 'file-123', +}; + +const mockFileResponse = { + data: { + id: 'file-123', + fileName: 'test-file.pdf', + status: 'available', + downloadUrl: 'https://api.airtop.com/files/file-123/download', + }, +}; + +const mockBinaryBuffer = Buffer.from('mock-binary-data'); + +const mockPreparedBinaryData = { + mimeType: 'application/pdf', + fileType: 'pdf', + fileName: 'test-file.pdf', + data: 'mock-base64-data', +}; + +jest.mock('../../../transport', () => { + const originalModule = jest.requireActual('../../../transport'); + return { + ...originalModule, + apiRequest: jest.fn(), + }; +}); + +describe('Test Airtop, get file operation', () => { + afterAll(() => { + jest.unmock('../../../transport'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should get file details successfully', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock.mockResolvedValueOnce(mockFileResponse); + + const result = await get.execute.call(createMockExecuteFunction(baseNodeParameters), 0); + + expect(apiRequestMock).toHaveBeenCalledWith('GET', '/files/file-123'); + + expect(result).toEqual([ + { + json: { + ...mockFileResponse, + }, + }, + ]); + }); + + it('should output file with binary data when specified', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock.mockResolvedValueOnce(mockFileResponse); + + const nodeParameters = { + ...baseNodeParameters, + outputBinaryFile: true, + }; + + const mockExecuteFunction = createMockExecuteFunction(nodeParameters); + + mockExecuteFunction.helpers.httpRequest = jest.fn().mockResolvedValue(mockBinaryBuffer); + mockExecuteFunction.helpers.prepareBinaryData = jest + .fn() + .mockResolvedValue(mockPreparedBinaryData); + + const result = await get.execute.call(mockExecuteFunction, 0); + + expect(apiRequestMock).toHaveBeenCalledWith('GET', '/files/file-123'); + + expect(result).toEqual([ + { + json: { + ...mockFileResponse, + }, + binary: { data: mockPreparedBinaryData }, + }, + ]); + }); + + it('should throw error when fileId is empty', async () => { + const nodeParameters = { + ...baseNodeParameters, + fileId: '', + }; + + await expect(get.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow( + ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'File ID'), + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/file/getMany.test.ts b/packages/nodes-base/nodes/Airtop/test/node/file/getMany.test.ts new file mode 100644 index 0000000000..198404400e --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/file/getMany.test.ts @@ -0,0 +1,119 @@ +import * as getMany from '../../../actions/file/getMany.operation'; +import * as transport from '../../../transport'; +import { createMockExecuteFunction } from '../helpers'; + +const baseNodeParameters = { + resource: 'file', + operation: 'getMany', + sessionId: 'test-session-123', + returnAll: true, + outputSingleItem: true, +}; + +const mockFilesResponse = { + data: { + files: [ + { + id: 'file-123', + name: 'document1.pdf', + size: 12345, + contentType: 'application/pdf', + createdAt: '2023-06-15T10:30:00Z', + }, + { + id: 'file-456', + name: 'image1.jpg', + size: 54321, + contentType: 'image/jpeg', + createdAt: '2023-06-16T11:45:00Z', + }, + ], + pagination: { + hasMore: false, + }, + }, +}; + +const mockPaginatedResponse = { + data: { + files: [mockFilesResponse.data.files[0]], + pagination: { + hasMore: true, + }, + }, +}; + +jest.mock('../../../transport', () => { + const originalModule = jest.requireActual('../../../transport'); + return { + ...originalModule, + apiRequest: jest.fn(), + }; +}); + +describe('Test Airtop, get many files operation', () => { + afterAll(() => { + jest.unmock('../../../transport'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should get all files successfully', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock.mockResolvedValueOnce(mockFilesResponse); + + const result = await getMany.execute.call(createMockExecuteFunction(baseNodeParameters), 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'GET', + '/files', + {}, + { + limit: 100, + offset: 0, + sessionIds: '', + }, + ); + + expect(result).toEqual([ + { + json: { + ...mockFilesResponse, + }, + }, + ]); + }); + + it('should handle limited results', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock.mockResolvedValueOnce(mockPaginatedResponse); + + const nodeParameters = { + ...baseNodeParameters, + returnAll: false, + limit: 1, + }; + + const result = await getMany.execute.call(createMockExecuteFunction(nodeParameters), 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'GET', + '/files', + {}, + { + limit: 1, + sessionIds: '', + }, + ); + + expect(result).toEqual([ + { + json: { + ...mockPaginatedResponse, + }, + }, + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/file/helpers.test.ts b/packages/nodes-base/nodes/Airtop/test/node/file/helpers.test.ts new file mode 100644 index 0000000000..696d617289 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/file/helpers.test.ts @@ -0,0 +1,353 @@ +import * as helpers from '../../../actions/file/helpers'; +import { BASE_URL } from '../../../constants'; +import * as transport from '../../../transport'; +import { createMockExecuteFunction } from '../helpers'; + +const mockFileCreateResponse = { + data: { + id: 'file-123', + uploadUrl: 'https://upload.example.com/url', + }, +}; + +const mockFileEvent = ` +event: fileEvent +data: {"event":"file_upload_status","status":"available","fileId":"file-123"}`; + +// Mock the transport and other dependencies +jest.mock('../../../transport', () => { + const originalModule = jest.requireActual('../../../transport'); + return { + ...originalModule, + apiRequest: jest.fn(async () => {}), + }; +}); + +describe('Test Airtop file helpers', () => { + afterAll(() => { + jest.unmock('../../../transport'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('requestAllFiles', () => { + it('should request all files with pagination', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + const mockFilesResponse1 = { + data: { + files: [{ id: 'file-1' }, { id: 'file-2' }], + pagination: { hasMore: true }, + }, + }; + + const mockFilesResponse2 = { + data: { + files: [{ id: 'file-3' }], + pagination: { hasMore: false }, + }, + }; + + apiRequestMock + .mockResolvedValueOnce(mockFilesResponse1) + .mockResolvedValueOnce(mockFilesResponse2); + + const result = await helpers.requestAllFiles.call( + createMockExecuteFunction({}), + 'session-123', + ); + + expect(apiRequestMock).toHaveBeenCalledTimes(2); + expect(apiRequestMock).toHaveBeenNthCalledWith( + 1, + 'GET', + '/files', + {}, + { offset: 0, limit: 100, sessionIds: 'session-123' }, + ); + expect(apiRequestMock).toHaveBeenNthCalledWith( + 2, + 'GET', + '/files', + {}, + { offset: 100, limit: 100, sessionIds: 'session-123' }, + ); + + expect(result).toEqual({ + data: { + files: [{ id: 'file-1' }, { id: 'file-2' }, { id: 'file-3' }], + pagination: { hasMore: false }, + }, + }); + }); + + it('should handle empty response', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + const mockEmptyResponse = { + data: { + files: [], + pagination: { hasMore: false }, + }, + }; + + apiRequestMock.mockResolvedValueOnce(mockEmptyResponse); + + const result = await helpers.requestAllFiles.call( + createMockExecuteFunction({}), + 'session-123', + ); + + expect(apiRequestMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + data: { + files: [], + pagination: { hasMore: false }, + }, + }); + }); + }); + + describe('pollFileUntilAvailable', () => { + it('should poll until file is available', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock + .mockResolvedValueOnce({ data: { status: 'uploading' } }) + .mockResolvedValueOnce({ data: { status: 'available' } }); + + const pollPromise = helpers.pollFileUntilAvailable.call( + createMockExecuteFunction({}), + 'file-123', + 1000, + 0, + ); + + const result = await pollPromise; + + expect(apiRequestMock).toHaveBeenCalledTimes(2); + expect(apiRequestMock).toHaveBeenCalledWith('GET', '/files/file-123'); + expect(result).toBe('file-123'); + }); + + it('should throw timeout error if file never becomes available', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock.mockResolvedValue({ data: { status: 'processing' } }); + + const promise = helpers.pollFileUntilAvailable.call( + createMockExecuteFunction({}), + 'file-123', + 0, + ); + + await expect(promise).rejects.toThrow(); + }); + }); + + describe('createAndUploadFile', () => { + it('should create file entry, upload file, and poll until available', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock + .mockResolvedValueOnce(mockFileCreateResponse) + .mockResolvedValueOnce({ data: { status: 'available' } }); + + const mockExecuteFunction = createMockExecuteFunction({}); + const mockHttpRequest = jest.fn().mockResolvedValueOnce({}); + mockExecuteFunction.helpers.httpRequest = mockHttpRequest; + const pollingFunctionMock = jest.fn().mockResolvedValueOnce(mockFileCreateResponse.data.id); + + const result = await helpers.createAndUploadFile.call( + mockExecuteFunction, + 'test.png', + Buffer.from('test'), + 'customer_upload', + pollingFunctionMock, + ); + + expect(apiRequestMock).toHaveBeenCalledWith('POST', '/files', { + fileName: 'test.png', + fileType: 'customer_upload', + }); + + expect(mockHttpRequest).toHaveBeenCalledWith({ + method: 'PUT', + url: mockFileCreateResponse.data.uploadUrl, + body: Buffer.from('test'), + headers: { + 'Content-Type': 'application/octet-stream', + }, + }); + + expect(pollingFunctionMock).toHaveBeenCalledWith(mockFileCreateResponse.data.id); + + expect(result).toBe(mockFileCreateResponse.data.id); + }); + + it('should throw error if file creation response is missing id or upload URL', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock.mockResolvedValueOnce({}); + + await expect( + helpers.createAndUploadFile.call( + createMockExecuteFunction({}), + 'test.pdf', + Buffer.from('test'), + 'customer_upload', + ), + ).rejects.toThrow(); + }); + }); + + describe('waitForFileInSession', () => { + it('should resolve when file is available', async () => { + // Create a mock stream + const mockStream = { + on: jest.fn().mockImplementation((event, callback) => { + if (event === 'data') { + callback(mockFileEvent); + } + }), + removeAllListeners: jest.fn(), + }; + + const mockHttpRequestWithAuthentication = jest.fn().mockResolvedValueOnce(mockStream); + const mockExecuteFunction = createMockExecuteFunction({}); + mockExecuteFunction.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication; + + await helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 100); + + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('airtopApi', { + method: 'GET', + url: `${BASE_URL}/sessions/session-123/events?all=true`, + encoding: 'stream', + }); + + expect(mockStream.removeAllListeners).toHaveBeenCalled(); + }); + + it('should timeout if no event is received', async () => { + // Create a mock stream + const mockStream = { + on: jest.fn().mockImplementation(() => {}), + removeAllListeners: jest.fn(), + }; + const mockHttpRequestWithAuthentication = jest.fn().mockResolvedValueOnce(mockStream); + + const mockExecuteFunction = createMockExecuteFunction({}); + mockExecuteFunction.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication; + + const waitPromise = helpers.waitForFileInSession.call( + mockExecuteFunction, + 'session-123', + 'file-123', + 100, + ); + + await expect(waitPromise).rejects.toThrow(); + }); + }); + + describe('pushFileToSession', () => { + it('should push file to session and wait', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + const mockFileId = 'file-123'; + const mockSessionId = 'session-123'; + apiRequestMock.mockResolvedValueOnce({}); + + // Mock waitForFileInSession + const waitForFileInSessionMock = jest.fn().mockResolvedValueOnce({}); + + // Call the function + await helpers.pushFileToSession.call( + createMockExecuteFunction({}), + mockFileId, + mockSessionId, + waitForFileInSessionMock, + ); + + expect(apiRequestMock).toHaveBeenCalledWith('POST', `/files/${mockFileId}/push`, { + sessionIds: [mockSessionId], + }); + + expect(waitForFileInSessionMock).toHaveBeenCalledWith(mockSessionId, mockFileId); + }); + }); + + describe('triggerFileInput', () => { + it('should trigger file input in window', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock.mockResolvedValueOnce({}); + const mockFileId = 'file-123'; + const mockWindowId = 'window-123'; + const mockSessionId = 'session-123'; + + await helpers.triggerFileInput.call( + createMockExecuteFunction({}), + mockFileId, + mockWindowId, + mockSessionId, + ); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + `/sessions/${mockSessionId}/windows/${mockWindowId}/file-input`, + { fileId: mockFileId }, + ); + }); + }); + + describe('createFileBuffer', () => { + it('should create buffer from URL', async () => { + const mockUrl = 'https://example.com/file.pdf'; + const mockBuffer = [1, 2, 3]; + + // Mock http request + const mockHttpRequest = jest.fn().mockResolvedValueOnce(mockBuffer); + + // Create mock execute function with http request helper + const mockExecuteFunction = createMockExecuteFunction({}); + mockExecuteFunction.helpers.httpRequest = mockHttpRequest; + + const result = await helpers.createFileBuffer.call(mockExecuteFunction, 'url', mockUrl, 0); + + expect(mockHttpRequest).toHaveBeenCalledWith({ + url: mockUrl, + json: false, + encoding: 'arraybuffer', + }); + expect(result).toBe(mockBuffer); + }); + + it('should create buffer from binary data', async () => { + const mockBinaryPropertyName = 'data'; + const mockBuffer = [1, 2, 3]; + + // Mock getBinaryDataBuffer + const mockGetBinaryDataBuffer = jest.fn().mockResolvedValue(mockBuffer); + + // Create mock execute function with getBinaryDataBuffer helper + const mockExecuteFunction = createMockExecuteFunction({}); + mockExecuteFunction.helpers.getBinaryDataBuffer = mockGetBinaryDataBuffer; + + const result = await helpers.createFileBuffer.call( + mockExecuteFunction, + 'binary', + mockBinaryPropertyName, + 0, + ); + + expect(mockGetBinaryDataBuffer).toHaveBeenCalledWith(0, mockBinaryPropertyName); + expect(result).toBe(mockBuffer); + }); + + it('should throw error for unsupported source type', async () => { + await expect( + helpers.createFileBuffer.call( + createMockExecuteFunction({}), + 'invalid-source', + 'test-value', + 0, + ), + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/interaction/fill.test.ts b/packages/nodes-base/nodes/Airtop/test/node/interaction/fill.test.ts new file mode 100644 index 0000000000..23f2e43a93 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/interaction/fill.test.ts @@ -0,0 +1,147 @@ +import * as fill from '../../../actions/interaction/fill.operation'; +import { ERROR_MESSAGES } from '../../../constants'; +import * as transport from '../../../transport'; +import { createMockExecuteFunction } from '../helpers'; + +const baseNodeParameters = { + resource: 'interaction', + operation: 'fill', + sessionId: 'test-session-123', + windowId: 'win-123', + formData: 'Name: John Doe, Email: john@example.com', +}; + +const mockAsyncResponse = { + requestId: 'req-123', + status: 'pending', +}; + +const mockCompletedResponse = { + status: 'completed', + data: { + success: true, + message: 'Form filled successfully', + }, +}; + +jest.mock('../../../transport', () => { + const originalModule = jest.requireActual('../../../transport'); + return { + ...originalModule, + apiRequest: jest.fn(), + }; +}); + +describe('Test Airtop, fill form operation', () => { + afterAll(() => { + jest.unmock('../../../transport'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should execute fill operation successfully', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + + // Mock the initial async request + apiRequestMock.mockResolvedValueOnce(mockAsyncResponse); + + // Mock the status check to return completed after first pending + apiRequestMock + .mockResolvedValueOnce({ ...mockAsyncResponse }) + .mockResolvedValueOnce(mockCompletedResponse); + + const result = await fill.execute.call(createMockExecuteFunction(baseNodeParameters), 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/async/sessions/test-session-123/windows/win-123/execute-automation', + { + automationId: 'auto', + parameters: { + customData: 'Name: John Doe, Email: john@example.com', + }, + }, + ); + + expect(apiRequestMock).toHaveBeenCalledWith('GET', '/requests/req-123/status'); + + expect(result).toEqual([ + { + json: { + sessionId: baseNodeParameters.sessionId, + windowId: baseNodeParameters.windowId, + status: 'completed', + data: { + success: true, + message: 'Form filled successfully', + }, + }, + }, + ]); + }); + + it("should throw error when 'formData' parameter is empty", async () => { + const nodeParameters = { + ...baseNodeParameters, + formData: '', + }; + + await expect(fill.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow( + ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Form Data'), + ); + }); + + it('should throw error when operation times out after 2 sec', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + const nodeParameters = { + ...baseNodeParameters, + }; + const timeout = 2000; + + // Mock the initial async request + apiRequestMock.mockResolvedValueOnce(mockAsyncResponse); + + // Return pending on all requests + apiRequestMock.mockResolvedValue({ ...mockAsyncResponse }); + + // should throw NodeApiError + await expect( + fill.execute.call(createMockExecuteFunction(nodeParameters), 0, timeout), + ).rejects.toThrow('The service was not able to process your request'); + }); + + it('should handle error status in response', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + const errorResponse = { + status: 'error', + error: { + message: 'Failed to fill form', + }, + }; + + // Mock the initial async request + apiRequestMock.mockResolvedValueOnce(mockAsyncResponse); + + // Mock the status check to return error + apiRequestMock + .mockResolvedValueOnce({ ...mockAsyncResponse }) + .mockResolvedValueOnce(errorResponse); + + const result = await fill.execute.call(createMockExecuteFunction(baseNodeParameters), 0); + + expect(result).toEqual([ + { + json: { + sessionId: baseNodeParameters.sessionId, + windowId: baseNodeParameters.windowId, + status: 'error', + error: { + message: 'Failed to fill form', + }, + }, + }, + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/node/interaction/scroll.test.ts b/packages/nodes-base/nodes/Airtop/test/node/interaction/scroll.test.ts new file mode 100644 index 0000000000..126c2b330e --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/interaction/scroll.test.ts @@ -0,0 +1,171 @@ +import * as scroll from '../../../actions/interaction/scroll.operation'; +import { ERROR_MESSAGES } from '../../../constants'; +import * as transport from '../../../transport'; +import { createMockExecuteFunction } from '../helpers'; + +const baseNodeParameters = { + resource: 'interaction', + operation: 'scroll', + sessionId: 'test-session-123', + windowId: 'win-123', + additionalFields: {}, +}; + +const baseAutomaticNodeParameters = { + ...baseNodeParameters, + scrollingMode: 'automatic', + scrollToElement: 'the bottom of the page', + scrollWithin: '', +}; + +const baseManualNodeParameters = { + ...baseNodeParameters, + scrollingMode: 'manual', + scrollToEdge: { + edgeValues: { + yAxis: 'bottom', + xAxis: '', + }, + }, + scrollBy: { + scrollValues: { + yAxis: '200px', + xAxis: '', + }, + }, +}; + +const mockResponse = { + success: true, + message: 'Scrolled successfully', +}; + +jest.mock('../../../transport', () => { + const originalModule = jest.requireActual('../../../transport'); + return { + ...originalModule, + apiRequest: jest.fn(), + }; +}); + +describe('Test Airtop, scroll operation', () => { + afterAll(() => { + jest.unmock('../../../transport'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should execute automatic scroll operation successfully', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock.mockResolvedValueOnce(mockResponse); + + const result = await scroll.execute.call( + createMockExecuteFunction(baseAutomaticNodeParameters), + 0, + ); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/sessions/test-session-123/windows/win-123/scroll', + { + scrollToElement: 'the bottom of the page', + scrollWithin: '', + configuration: {}, + }, + ); + + expect(result).toEqual([ + { + json: { + sessionId: baseAutomaticNodeParameters.sessionId, + windowId: baseAutomaticNodeParameters.windowId, + success: true, + message: 'Scrolled successfully', + }, + }, + ]); + }); + + it('should execute manual scroll operation successfully', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + apiRequestMock.mockResolvedValueOnce(mockResponse); + + const result = await scroll.execute.call( + createMockExecuteFunction(baseManualNodeParameters), + 0, + ); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/sessions/test-session-123/windows/win-123/scroll', + { + configuration: {}, + scrollToEdge: { + yAxis: 'bottom', + xAxis: '', + }, + scrollBy: { + yAxis: '200px', + xAxis: '', + }, + }, + ); + + expect(result).toEqual([ + { + json: { + sessionId: baseManualNodeParameters.sessionId, + windowId: baseManualNodeParameters.windowId, + success: true, + message: 'Scrolled successfully', + }, + }, + ]); + }); + + it("should throw error when scrollingMode is 'automatic' and 'scrollToElement' parameter is empty", async () => { + const nodeParameters = { + ...baseAutomaticNodeParameters, + scrollToElement: '', + }; + + await expect(scroll.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow( + ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Element Description'), + ); + }); + + it("should validate scroll amount formats when scrollingMode is 'manual'", async () => { + const invalidNodeParameters = { + ...baseManualNodeParameters, + scrollBy: { + scrollValues: { + yAxis: 'one hundred pixels', + xAxis: '', + }, + }, + }; + + await expect( + scroll.execute.call(createMockExecuteFunction(invalidNodeParameters), 0), + ).rejects.toThrow(ERROR_MESSAGES.SCROLL_BY_AMOUNT_INVALID); + }); + + it('should throw an error when the API returns an error response', async () => { + const apiRequestMock = transport.apiRequest as jest.Mock; + const errorResponse = { + errors: [ + { + message: 'Failed to scroll', + }, + ], + }; + + apiRequestMock.mockResolvedValueOnce(errorResponse); + + await expect( + scroll.execute.call(createMockExecuteFunction(baseAutomaticNodeParameters), 0), + ).rejects.toThrow('Failed to scroll'); + }); +}); 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 index 04a7d34127..6130793edc 100644 --- a/packages/nodes-base/nodes/Airtop/test/node/session/create.test.ts +++ b/packages/nodes-base/nodes/Airtop/test/node/session/create.test.ts @@ -1,16 +1,27 @@ import * as create from '../../../actions/session/create.operation'; -import { ERROR_MESSAGES } from '../../../constants'; +import { ERROR_MESSAGES, SESSION_STATUS } from '../../../constants'; import * as transport from '../../../transport'; import { createMockExecuteFunction } from '../helpers'; +const mockCreatedSession = { + data: { id: 'test-session-123', status: SESSION_STATUS.RUNNING }, +}; + +const baseNodeParameters = { + resource: 'session', + operation: 'create', + profileName: 'test-profile', + timeoutMinutes: 10, + saveProfileOnTermination: false, +}; + jest.mock('../../../transport', () => { const originalModule = jest.requireActual('../../../transport'); return { ...originalModule, apiRequest: jest.fn(async function () { return { - sessionId: 'test-session-123', - status: 'success', + ...mockCreatedSession, }; }), }; @@ -24,31 +35,26 @@ describe('Test Airtop, session create operation', () => { afterEach(() => { jest.clearAllMocks(); }); - + /** + * Minimal parameters + */ it('should create a session with minimal parameters', async () => { const nodeParameters = { - resource: 'session', - operation: 'create', - profileName: 'test-profile', - timeoutMinutes: 10, - saveProfileOnTermination: false, + ...baseNodeParameters, 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(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', { + configuration: { + profileName: 'test-profile', + solveCaptcha: false, + timeoutMinutes: 10, + proxy: false, }, - ); + }); expect(result).toEqual([ { @@ -58,13 +64,12 @@ describe('Test Airtop, session create operation', () => { }, ]); }); - + /** + * Profiles + */ it('should create a session with save profile enabled', async () => { const nodeParameters = { - resource: 'session', - operation: 'create', - profileName: 'test-profile', - timeoutMinutes: 15, + ...baseNodeParameters, saveProfileOnTermination: true, proxy: 'none', }; @@ -72,18 +77,14 @@ describe('Test Airtop, session create operation', () => { 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(1, 'POST', '/sessions', { + configuration: { + profileName: 'test-profile', + solveCaptcha: false, + timeoutMinutes: 10, + proxy: false, }, - ); + }); expect(transport.apiRequest).toHaveBeenNthCalledWith( 2, 'PUT', @@ -98,31 +99,27 @@ describe('Test Airtop, session create operation', () => { }, ]); }); - - it('should create a session with integrated proxy', async () => { + /** + * Proxy + */ + it('should create a session with integrated proxy and empty config', async () => { const nodeParameters = { - resource: 'session', - operation: 'create', - profileName: 'test-profile', - timeoutMinutes: 10, - saveProfileOnTermination: false, + ...baseNodeParameters, proxy: 'integrated', + proxyConfig: {}, // simulate integrated proxy with empty config }; 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(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', { + configuration: { + profileName: 'test-profile', + solveCaptcha: false, + timeoutMinutes: 10, + proxy: true, }, - ); + }); expect(result).toEqual([ { @@ -133,31 +130,52 @@ describe('Test Airtop, session create operation', () => { ]); }); - it('should create a session with custom proxy', async () => { + it('should create a session with integrated proxy and proxy configuration', async () => { const nodeParameters = { - resource: 'session', - operation: 'create', - profileName: 'test-profile', - timeoutMinutes: 10, - saveProfileOnTermination: false, - proxy: 'custom', + ...baseNodeParameters, + proxy: 'integrated', + proxyConfig: { country: 'US', sticky: true }, + }; + + const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0); + + expect(transport.apiRequest).toHaveBeenCalledTimes(1); + expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', { + configuration: { + profileName: 'test-profile', + solveCaptcha: false, + timeoutMinutes: 10, + proxy: { country: 'US', sticky: true }, + }, + }); + + expect(result).toEqual([ + { + json: { + sessionId: 'test-session-123', + }, + }, + ]); + }); + + it('should create a session with proxy URL', async () => { + const nodeParameters = { + ...baseNodeParameters, + proxy: 'proxyUrl', 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(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', { + configuration: { + profileName: 'test-profile', + solveCaptcha: false, + timeoutMinutes: 10, + proxy: 'http://proxy.example.com:8080', }, - ); + }); expect(result).toEqual([ { @@ -168,30 +186,10 @@ describe('Test Airtop, session create operation', () => { ]); }); - 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', + ...baseNodeParameters, + proxy: 'proxyUrl', proxyUrl: '', }; @@ -199,4 +197,67 @@ describe('Test Airtop, session create operation', () => { ERROR_MESSAGES.PROXY_URL_REQUIRED, ); }); + /** + * Auto solve captcha + */ + it('should create a session with auto solve captcha enabled', async () => { + const nodeParameters = { + ...baseNodeParameters, + additionalFields: { + solveCaptcha: true, + }, + }; + + const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0); + + expect(transport.apiRequest).toHaveBeenCalledTimes(1); + expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', { + configuration: { + profileName: 'test-profile', + solveCaptcha: true, + timeoutMinutes: 10, + proxy: false, + }, + }); + + expect(result).toEqual([ + { + json: { + sessionId: 'test-session-123', + }, + }, + ]); + }); + /** + * Chrome extensions + */ + it('should create a session with chrome extensions enabled', async () => { + const nodeParameters = { + ...baseNodeParameters, + additionalFields: { + extensionIds: 'extId1, extId2', + }, + }; + + const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0); + + expect(transport.apiRequest).toHaveBeenCalledTimes(1); + expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', { + configuration: { + profileName: 'test-profile', + solveCaptcha: false, + timeoutMinutes: 10, + proxy: false, + extensionIds: ['extId1', 'extId2'], + }, + }); + + expect(result).toEqual([ + { + json: { + sessionId: 'test-session-123', + }, + }, + ]); + }); }); 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 index 1d4f2337c0..32b41b386c 100644 --- a/packages/nodes-base/nodes/Airtop/test/node/window/takeScreenshot.test.ts +++ b/packages/nodes-base/nodes/Airtop/test/node/window/takeScreenshot.test.ts @@ -19,6 +19,14 @@ const mockResponse = { const mockBinaryBuffer = Buffer.from('mock-binary-data'); +const expectedJsonResult = { + json: { + sessionId: 'test-session-123', + windowId: 'win-123', + image: 'base64-encoded-image-data', + }, +}; + const expectedBinaryResult = { binary: { data: { @@ -61,7 +69,7 @@ describe('Test Airtop, take screenshot operation', () => { jest.clearAllMocks(); }); - it('should take screenshot successfully', async () => { + it('should take screenshot in base64 format', async () => { const result = await takeScreenshot.execute.call( createMockExecuteFunction({ ...baseNodeParameters }), 0, @@ -73,23 +81,14 @@ describe('Test Airtop, take screenshot operation', () => { '/sessions/test-session-123/windows/win-123/screenshot', ); - expect(result).toEqual([ - { - json: { - sessionId: 'test-session-123', - windowId: 'win-123', - status: 'success', - ...mockResponse, - }, - ...expectedBinaryResult, - }, - ]); + expect(result).toEqual([{ ...expectedJsonResult }]); }); - it('should transform screenshot to binary data', async () => { + it('should take screenshot in binary format', async () => { const result = await takeScreenshot.execute.call( createMockExecuteFunction({ ...baseNodeParameters, + outputImageAsBinary: true, }), 0, ); @@ -106,12 +105,7 @@ describe('Test Airtop, take screenshot operation', () => { expect(result).toEqual([ { - json: { - sessionId: 'test-session-123', - windowId: 'win-123', - status: 'success', - ...mockResponse, - }, + json: { ...expectedJsonResult.json, image: '' }, ...expectedBinaryResult, }, ]); diff --git a/packages/nodes-base/nodes/Airtop/test/session-utils.test.ts b/packages/nodes-base/nodes/Airtop/test/session-utils.test.ts index a42c655721..fad3768e6d 100644 --- a/packages/nodes-base/nodes/Airtop/test/session-utils.test.ts +++ b/packages/nodes-base/nodes/Airtop/test/session-utils.test.ts @@ -60,13 +60,9 @@ describe('executeRequestWithSessionManagement', () => { }, ); - expect(result).toEqual([ - { - json: { - success: true, - }, - }, - ]); + expect(result).toEqual({ + success: true, + }); }); it("should not terminate session when 'autoTerminateSession' is false", async () => { @@ -91,15 +87,11 @@ describe('executeRequestWithSessionManagement', () => { '/sessions/existing-session-123', ); - expect(result).toEqual([ - { - json: { - sessionId: 'new-session-123', - windowId: 'new-window-123', - success: true, - }, - }, - ]); + expect(result).toEqual({ + sessionId: 'new-session-123', + windowId: 'new-window-123', + success: true, + }); }); it("should terminate session when 'autoTerminateSession' is true", async () => { diff --git a/packages/nodes-base/nodes/Airtop/test/utils.test.ts b/packages/nodes-base/nodes/Airtop/test/utils.test.ts index 08f744a1c2..a815228c3d 100644 --- a/packages/nodes-base/nodes/Airtop/test/utils.test.ts +++ b/packages/nodes-base/nodes/Airtop/test/utils.test.ts @@ -1,8 +1,9 @@ import { NodeApiError } from 'n8n-workflow'; import { createMockExecuteFunction } from './node/helpers'; -import { ERROR_MESSAGES } from '../constants'; +import { ERROR_MESSAGES, SESSION_STATUS } from '../constants'; import { + createSession, createSessionAndWindow, validateProfileName, validateTimeoutMinutes, @@ -11,20 +12,35 @@ import { validateAirtopApiResponse, validateSessionId, validateUrl, + validateProxy, validateRequiredStringField, shouldCreateNewSession, convertScreenshotToBinary, } from '../GenericFunctions'; import type * as transport from '../transport'; +const mockCreatedSession = { + data: { id: 'new-session-123', status: SESSION_STATUS.RUNNING }, +}; + jest.mock('../transport', () => { const originalModule = jest.requireActual('../transport'); return { ...originalModule, - apiRequest: jest.fn(async (method: string, endpoint: string) => { + apiRequest: jest.fn(async (method: string, endpoint: string, params: { fail?: boolean }) => { + // return failed request + if (endpoint.endsWith('/sessions') && params.fail) { + return {}; + } + // create session - if (endpoint.includes('/create-session')) { - return { sessionId: 'new-session-123' }; + if (method === 'POST' && endpoint.endsWith('/sessions')) { + return { ...mockCreatedSession }; + } + + // get session status - general case + if (method === 'GET' && endpoint.includes('/sessions')) { + return { ...mockCreatedSession }; } // create window @@ -343,6 +359,57 @@ describe('Test Airtop utils', () => { }); }); + describe('validateProxy', () => { + it('should validate intergated proxy', () => { + const nodeParameters = { + proxy: 'integrated', + }; + + const result = validateProxy.call(createMockExecuteFunction(nodeParameters), 0); + expect(result).toEqual({ proxy: true }); + }); + + it('should validate proxyUrl', () => { + const nodeParameters = { + proxy: 'proxyUrl', + proxyUrl: 'http://example.com', + }; + + const result = validateProxy.call(createMockExecuteFunction(nodeParameters), 0); + expect(result).toEqual({ proxy: 'http://example.com' }); + }); + + it('should throw error for empty proxyUrl', () => { + const nodeParameters = { + proxy: 'proxyUrl', + proxyUrl: '', + }; + + expect(() => validateProxy.call(createMockExecuteFunction(nodeParameters), 0)).toThrow( + ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Proxy URL'), + ); + }); + + it('should validate integrated proxy with config', () => { + const nodeParameters = { + proxy: 'integrated', + proxyConfig: { country: 'US', sticky: true }, + }; + + const result = validateProxy.call(createMockExecuteFunction(nodeParameters), 0); + expect(result).toEqual({ proxy: { country: 'US', sticky: true } }); + }); + + it('should validate none proxy', () => { + const nodeParameters = { + proxy: 'none', + }; + + const result = validateProxy.call(createMockExecuteFunction(nodeParameters), 0); + expect(result).toEqual({ proxy: false }); + }); + }); + describe('validateAirtopApiResponse', () => { const mockNode = { id: '1', @@ -415,6 +482,19 @@ describe('Test Airtop utils', () => { }); }); + describe('createSession', () => { + it('should create a session and return the session ID', async () => { + const result = await createSession.call(createMockExecuteFunction({}), {}); + expect(result).toEqual({ sessionId: 'new-session-123' }); + }); + + it('should throw an error if no session ID is returned', async () => { + await expect( + createSession.call(createMockExecuteFunction({}), { fail: true }), + ).rejects.toThrow(); + }); + }); + describe('createSessionAndWindow', () => { it("should create a new session and window when sessionMode is 'new'", async () => { const nodeParameters = { diff --git a/packages/nodes-base/nodes/Airtop/transport/index.ts b/packages/nodes-base/nodes/Airtop/transport/index.ts index a4b1cbad0e..94e5997043 100644 --- a/packages/nodes-base/nodes/Airtop/transport/index.ts +++ b/packages/nodes-base/nodes/Airtop/transport/index.ts @@ -7,7 +7,13 @@ import type { } from 'n8n-workflow'; import type { IAirtopResponse } from './types'; -import { BASE_URL } from '../constants'; +import { BASE_URL, N8N_VERSION } from '../constants'; + +const defaultHeaders = { + 'Content-Type': 'application/json', + 'x-airtop-sdk-environment': 'n8n', + 'x-airtop-sdk-version': N8N_VERSION, +}; export async function apiRequest( this: IExecuteFunctions | ILoadOptionsFunctions, @@ -17,9 +23,7 @@ export async function apiRequest( query: IDataObject = {}, ) { const options: IHttpRequestOptions = { - headers: { - 'Content-Type': 'application/json', - }, + headers: defaultHeaders, method, body, qs: query, diff --git a/packages/nodes-base/nodes/Airtop/transport/types.ts b/packages/nodes-base/nodes/Airtop/transport/types.ts index 18139fba22..7face1baf5 100644 --- a/packages/nodes-base/nodes/Airtop/transport/types.ts +++ b/packages/nodes-base/nodes/Airtop/transport/types.ts @@ -1,16 +1,39 @@ -import type { IDataObject } from 'n8n-workflow'; +import type { IDataObject, INodeExecutionData } from 'n8n-workflow'; + +export interface IAirtopSessionResponse extends IDataObject { + data: { + id: string; + status: string; + }; +} export interface IAirtopResponse extends IDataObject { sessionId?: string; - data: IDataObject & { + windowId?: string; + data?: IDataObject & { + windowId?: string; modelResponse?: string; + files?: IDataObject[]; }; - meta: IDataObject & { + meta?: IDataObject & { status?: string; screenshots?: Array<{ dataUrl: string }>; }; - errors: IDataObject[]; - warnings: IDataObject[]; + errors?: IDataObject[]; + warnings?: IDataObject[]; + output?: IDataObject; +} + +export interface IAirtopResponseWithFiles extends IAirtopResponse { + data: { + files: IDataObject[]; + fileName?: string; + status?: string; + downloadUrl?: string; + pagination: { + hasMore: boolean; + }; + }; } export interface IAirtopInteractionRequest extends IDataObject { @@ -18,6 +41,18 @@ export interface IAirtopInteractionRequest extends IDataObject { waitForNavigation?: boolean; elementDescription?: string; pressEnterKey?: boolean; + // scroll parameters + scrollToElement?: string; + scrollWithin?: string; + scrollToEdge?: { + xAxis?: string; + yAxis?: string; + }; + scrollBy?: { + xAxis?: string; + yAxis?: string; + }; + // configuration configuration: { visualAnalysis?: { scope: string; @@ -27,3 +62,17 @@ export interface IAirtopInteractionRequest extends IDataObject { }; }; } + +export interface IAirtopNodeExecutionData extends INodeExecutionData { + json: IAirtopResponse; +} + +export interface IAirtopServerEvent { + event: string; + eventData: { + error?: string; + }; + fileId?: string; + status?: string; + downloadUrl?: string; +} diff --git a/packages/nodes-base/test/setup.ts b/packages/nodes-base/test/setup.ts index ff0fa69274..15955498eb 100644 --- a/packages/nodes-base/test/setup.ts +++ b/packages/nodes-base/test/setup.ts @@ -4,3 +4,4 @@ import 'reflect-metadata'; // to mock the Code Node execution process.env.N8N_RUNNERS_ENABLED = 'false'; process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false'; +process.env.N8N_VERSION = '0.0.0-test';