feat(Airtop Node): Add File operations and scroll micro-interaction (#15089)

This commit is contained in:
Cesar Sanchez
2025-05-23 11:38:12 +02:00
committed by GitHub
parent b872e2354a
commit 86885a7d0e
41 changed files with 4002 additions and 237 deletions

View File

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

View File

@@ -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,
],

View File

@@ -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,
},

View File

@@ -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
*/

View File

@@ -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),
},
}));
}

View File

@@ -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<INodeExecutionData[]> {
): Promise<IAirtopResponse> {
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 };
}

View File

@@ -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);
}

View File

@@ -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<INodeExecutionData[]> {
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);
}

View File

@@ -6,9 +6,11 @@ export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
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 });
}

View File

@@ -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,
];

View File

@@ -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<INodeExecutionData[]> {
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' } });
}

View File

@@ -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<INodeExecutionData[]> {
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 });
}

View File

@@ -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 <a href="https://docs.airtop.ai/api-reference/airtop-api/sessions/create" target="_blank">Session IDs</a> 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<INodeExecutionData[]> {
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);
}

View File

@@ -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<IAirtopResponseWithFiles> {
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<string> {
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<string> {
// 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<IAirtopServerEvent>(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<void> {
const url = `${BASE_URL}/sessions/${sessionId}/events?all=true`;
const fileReadyPromise = new Promise<void>(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<void>((_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<void> {
// 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<void> {
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<Buffer> {
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,
});
}

View File

@@ -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<INodeExecutionData[]> {
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);
}
}

View File

@@ -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 <b>unique names</b>.',
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<INodeExecutionData[]> {
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);
}
}

View File

@@ -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: [

View File

@@ -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<INodeExecutionData[]> {
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 });
}

View File

@@ -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<INodeExecutionData[]> {
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 });
}

View File

@@ -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<NodeMap>;

View File

@@ -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<INodeExecutionData[][]> {
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<AirtopType>('resource', 0);
@@ -35,6 +40,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
case 'extraction':
responseData = await extraction[airtopNodeData.operation].execute.call(this, i);
break;
case 'file':
responseData = await file[airtopNodeData.operation].execute.call(this, i);
break;
default:
throw new NodeOperationError(
this.getNode(),
@@ -42,10 +50,15 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData: { item: i } },
);
// Get cleaner output when called as tool
if (isCalledAsTool && !['session', 'window'].includes(resource)) {
responseData = cleanOutputForToolUse(responseData);
}
const executionData = this.helpers.constructExecutionMetaData(responseData, {
itemData: { item: i },
});
operationResult.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {

View File

@@ -5,26 +5,30 @@ import {
type INodeProperties,
} from 'n8n-workflow';
import { INTEGRATION_URL } from '../../constants';
import { COUNTRIES } from '../../countries';
import {
validateAirtopApiResponse,
createSession,
validateProfileName,
validateProxyUrl,
validateProxy,
validateSaveProfileOnTermination,
validateTimeoutMinutes,
} from '../../GenericFunctions';
import { apiRequest } from '../../transport';
import { profileNameField } from '../common/fields';
const displayOptions = {
show: {
resource: ['session'],
operation: ['create'],
},
};
const countryOptions = COUNTRIES.map(({ name, value }) => ({ 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 <a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Airtop profile</a> for this session upon termination',
displayOptions: {
show: {
resource: ['session'],
operation: ['create'],
},
},
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 <a href="https://docs.airtop.ai/api-reference/airtop-api/sessions/create#request.body.configuration.proxy.Proxy.Airtop-Proxy-Configuration.country" target="_blank">here</a>.',
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 <a href="https://docs.airtop.ai/guides/how-to/solving-captchas" target="_blank">captcha challenges</a>',
},
{
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 <a href="https://docs.airtop.ai/guides/how-to/using-chrome-extensions" target="_blank">here</a>.',
},
],
},
];
export async function execute(
this: IExecuteFunctions,
index: number,
): Promise<INodeExecutionData[]> {
const url = `${INTEGRATION_URL}/create-session`;
const profileName = validateProfileName.call(this, index);
const timeoutMinutes = validateTimeoutMinutes.call(this, index);
const saveProfileOnTermination = validateSaveProfileOnTermination.call(this, index, profileName);
const proxyParam = this.getNodeParameter('proxy', index, 'none') as string;
const proxyUrl = validateProxyUrl.call(this, index, proxyParam);
const { 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(

View File

@@ -69,4 +69,5 @@ export const description: INodeProperties[] = [
},
...create.description,
...load.description,
...takeScreenshot.description,
];

View File

@@ -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',

View File

@@ -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<INodeExecutionData[]> {
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 } } : {}),
},

View File

@@ -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<n8n.PackageJson>(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;

View File

@@ -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;

View File

@@ -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,
},
},
]);
});
});

View File

@@ -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<typeof transport>('../../../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'));
});
});

View File

@@ -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<typeof transport>('../../../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'),
);
});
});

View File

@@ -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<typeof transport>('../../../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,
},
},
]);
});
});

View File

@@ -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<typeof transport>('../../../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();
});
});
});

View File

@@ -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<typeof transport>('../../../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',
},
},
},
]);
});
});

View File

@@ -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<typeof transport>('../../../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');
});
});

View File

@@ -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<typeof transport>('../../../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',
},
},
]);
});
});

View File

@@ -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,
},
]);

View File

@@ -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 () => {

View File

@@ -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<typeof transport>('../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 = {

View File

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

View File

@@ -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;
}

View File

@@ -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';