mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(Airtop Node): Add Airtop node (#13809)
This commit is contained in:
51
packages/nodes-base/credentials/AirtopApi.credentials.ts
Normal file
51
packages/nodes-base/credentials/AirtopApi.credentials.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
IAuthenticateGeneric,
|
||||
ICredentialType,
|
||||
ICredentialTestRequest,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export class AirtopApi implements ICredentialType {
|
||||
name = 'airtopApi';
|
||||
|
||||
displayName = 'Airtop API';
|
||||
|
||||
documentationUrl = 'airtop';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'The Airtop API key. You can create one at <a href="https://portal.airtop.ai/api-keys" target="_blank">Airtop</a> for free.',
|
||||
required: true,
|
||||
typeOptions: {
|
||||
password: true,
|
||||
},
|
||||
noDataExpression: true,
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
type: 'generic',
|
||||
properties: {
|
||||
headers: {
|
||||
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||
'api-key': '={{$credentials.apiKey}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
method: 'GET',
|
||||
baseURL: 'https://api.airtop.ai/api/v1',
|
||||
url: '/sessions',
|
||||
qs: {
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
18
packages/nodes-base/nodes/Airtop/Airtop.node.json
Normal file
18
packages/nodes-base/nodes/Airtop/Airtop.node.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.airtop",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Productivity", "Development"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/airtop/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.airtop/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
67
packages/nodes-base/nodes/Airtop/Airtop.node.ts
Normal file
67
packages/nodes-base/nodes/Airtop/Airtop.node.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import * as extraction from './actions/extraction/Extraction.resource';
|
||||
import * as interaction from './actions/interaction/Interaction.resource';
|
||||
import { router } from './actions/router';
|
||||
import * as session from './actions/session/Session.resource';
|
||||
import * as window from './actions/window/Window.resource';
|
||||
export class Airtop implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Airtop',
|
||||
name: 'airtop',
|
||||
icon: 'file:airtop.svg',
|
||||
group: ['transform'],
|
||||
defaultVersion: 1,
|
||||
version: [1],
|
||||
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
|
||||
description: 'Scrape and control any site with Airtop',
|
||||
usableAsTool: true,
|
||||
defaults: {
|
||||
name: 'Airtop',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'airtopApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Session',
|
||||
value: 'session',
|
||||
},
|
||||
{
|
||||
name: 'Window',
|
||||
value: 'window',
|
||||
},
|
||||
{
|
||||
name: 'Extraction',
|
||||
value: 'extraction',
|
||||
},
|
||||
{
|
||||
name: 'Interaction',
|
||||
value: 'interaction',
|
||||
},
|
||||
],
|
||||
default: 'session',
|
||||
},
|
||||
...session.description,
|
||||
...window.description,
|
||||
...extraction.description,
|
||||
...interaction.description,
|
||||
],
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
318
packages/nodes-base/nodes/Airtop/GenericFunctions.ts
Normal file
318
packages/nodes-base/nodes/Airtop/GenericFunctions.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { NodeApiError, type IExecuteFunctions, type INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { SESSION_MODE } from './actions/common/fields';
|
||||
import {
|
||||
ERROR_MESSAGES,
|
||||
DEFAULT_TIMEOUT_MINUTES,
|
||||
MIN_TIMEOUT_MINUTES,
|
||||
MAX_TIMEOUT_MINUTES,
|
||||
INTEGRATION_URL,
|
||||
} from './constants';
|
||||
import { apiRequest } from './transport';
|
||||
import type { IAirtopResponse } from './transport/types';
|
||||
|
||||
/**
|
||||
* Validate a required string field
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @param field - The field to validate
|
||||
* @param fieldName - The name of the field
|
||||
*/
|
||||
export function validateRequiredStringField(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
field: string,
|
||||
fieldName: string,
|
||||
) {
|
||||
let value = this.getNodeParameter(field, index) as string;
|
||||
value = (value || '').trim();
|
||||
const errorMessage = ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', fieldName);
|
||||
|
||||
if (!value) {
|
||||
throw new NodeOperationError(this.getNode(), errorMessage, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the session ID parameter
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @returns The validated session ID
|
||||
*/
|
||||
export function validateSessionId(this: IExecuteFunctions, index: number) {
|
||||
let sessionId = this.getNodeParameter('sessionId', index, '') as string;
|
||||
sessionId = (sessionId || '').trim();
|
||||
|
||||
if (!sessionId) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SESSION_ID_REQUIRED, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the session ID and window ID parameters
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @returns The validated session ID and window ID parameters
|
||||
*/
|
||||
export function validateSessionAndWindowId(this: IExecuteFunctions, index: number) {
|
||||
let sessionId = this.getNodeParameter('sessionId', index, '') as string;
|
||||
let windowId = this.getNodeParameter('windowId', index, '') as string;
|
||||
sessionId = (sessionId || '').trim();
|
||||
windowId = (windowId || '').trim();
|
||||
|
||||
if (!sessionId) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SESSION_ID_REQUIRED, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
if (!windowId) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.WINDOW_ID_REQUIRED, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
windowId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the profile name parameter
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @returns The validated profile name
|
||||
*/
|
||||
export function validateProfileName(this: IExecuteFunctions, index: number) {
|
||||
let profileName = this.getNodeParameter('profileName', index) as string;
|
||||
profileName = (profileName || '').trim();
|
||||
|
||||
if (!profileName) {
|
||||
return profileName;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(profileName)) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.PROFILE_NAME_INVALID, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return profileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the timeout minutes parameter
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @returns The validated timeout minutes
|
||||
*/
|
||||
export function validateTimeoutMinutes(this: IExecuteFunctions, index: number) {
|
||||
const timeoutMinutes = this.getNodeParameter(
|
||||
'timeoutMinutes',
|
||||
index,
|
||||
DEFAULT_TIMEOUT_MINUTES,
|
||||
) as number;
|
||||
|
||||
if (timeoutMinutes < MIN_TIMEOUT_MINUTES || timeoutMinutes > MAX_TIMEOUT_MINUTES) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.TIMEOUT_MINUTES_INVALID, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return timeoutMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the URL parameter
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @returns The validated URL
|
||||
*/
|
||||
export function validateUrl(this: IExecuteFunctions, index: number) {
|
||||
let url = this.getNodeParameter('url', index) as string;
|
||||
url = (url || '').trim();
|
||||
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!url.startsWith('http')) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.URL_INVALID, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the Proxy URL parameter
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @param proxy - The value of the Proxy parameter
|
||||
* @returns The validated proxy URL
|
||||
*/
|
||||
export function validateProxyUrl(this: IExecuteFunctions, index: number, proxy: string) {
|
||||
let proxyUrl = this.getNodeParameter('proxyUrl', index, '') as string;
|
||||
proxyUrl = (proxyUrl || '').trim();
|
||||
|
||||
// only validate proxyUrl if proxy is custom
|
||||
if (proxy !== 'custom') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!proxyUrl) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.PROXY_URL_REQUIRED, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
if (!proxyUrl.startsWith('http')) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.PROXY_URL_INVALID, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the screen resolution parameter
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @returns The validated screen resolution
|
||||
*/
|
||||
export function validateScreenResolution(this: IExecuteFunctions, index: number) {
|
||||
let screenResolution = this.getNodeParameter('screenResolution', index, '') as string;
|
||||
screenResolution = (screenResolution || '').trim().toLowerCase();
|
||||
const regex = /^\d{3,4}x\d{3,4}$/; // Expected format: 1280x720
|
||||
|
||||
if (!screenResolution) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!regex.test(screenResolution)) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SCREEN_RESOLUTION_INVALID, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return screenResolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the save profile on termination parameter
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @param profileName - The profile name
|
||||
* @returns The validated save profile on termination
|
||||
*/
|
||||
export function validateSaveProfileOnTermination(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
profileName: string,
|
||||
) {
|
||||
const saveProfileOnTermination = this.getNodeParameter(
|
||||
'saveProfileOnTermination',
|
||||
index,
|
||||
false,
|
||||
) as boolean;
|
||||
|
||||
if (saveProfileOnTermination && !profileName) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.PROFILE_NAME_REQUIRED, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return saveProfileOnTermination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is an error in the API response and throw NodeApiError
|
||||
* @param node - The node instance
|
||||
* @param response - The response from the API
|
||||
*/
|
||||
export function validateAirtopApiResponse(node: INode, response: IAirtopResponse) {
|
||||
if (response?.errors?.length) {
|
||||
const errorMessage = response.errors.map((error) => error.message).join('\n');
|
||||
throw new NodeApiError(node, {
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a screenshot from the API response to a binary buffer
|
||||
* @param screenshot - The screenshot from the API response
|
||||
* @returns The processed screenshot
|
||||
*/
|
||||
export function convertScreenshotToBinary(screenshot: { dataUrl: string }): Buffer {
|
||||
const base64Data = screenshot.dataUrl.replace('data:image/jpeg;base64,', '');
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a new session should be created
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @returns True if a new session should be created, false otherwise
|
||||
*/
|
||||
export function shouldCreateNewSession(this: IExecuteFunctions, index: number) {
|
||||
const sessionMode = this.getNodeParameter('sessionMode', index) as string;
|
||||
return Boolean(sessionMode && sessionMode === SESSION_MODE.NEW);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session and window
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @returns The session ID and window ID
|
||||
*/
|
||||
export async function createSessionAndWindow(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<{ sessionId: string; windowId: string }> {
|
||||
const node = this.getNode();
|
||||
const noCodeEndpoint = `${INTEGRATION_URL}/create-session`;
|
||||
const profileName = validateProfileName.call(this, index);
|
||||
const url = validateRequiredStringField.call(this, index, 'url', 'URL');
|
||||
|
||||
const { sessionId } = await apiRequest.call(this, 'POST', noCodeEndpoint, {
|
||||
configuration: {
|
||||
profileName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!sessionId) {
|
||||
throw new NodeApiError(node, {
|
||||
message: 'Failed to create session',
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
this.logger.info(`[${node.name}] Session successfully created.`);
|
||||
|
||||
const windowResponse = await apiRequest.call(this, 'POST', `/sessions/${sessionId}/windows`, {
|
||||
url,
|
||||
});
|
||||
const windowId = windowResponse?.data?.windowId as string;
|
||||
|
||||
if (!windowId) {
|
||||
throw new NodeApiError(node, {
|
||||
message: 'Failed to create window',
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
this.logger.info(`[${node.name}] Window successfully created.`);
|
||||
return { sessionId, windowId };
|
||||
}
|
||||
156
packages/nodes-base/nodes/Airtop/actions/common/fields.ts
Normal file
156
packages/nodes-base/nodes/Airtop/actions/common/fields.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const SESSION_MODE = {
|
||||
NEW: 'new',
|
||||
EXISTING: 'existing',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Session related fields
|
||||
*/
|
||||
|
||||
export const sessionIdField: INodeProperties = {
|
||||
displayName: 'Session ID',
|
||||
name: 'sessionId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json["sessionId"] }}',
|
||||
description:
|
||||
'The ID of the <a href="https://docs.airtop.ai/guides/how-to/creating-a-session" target="_blank">Session</a> to use',
|
||||
};
|
||||
|
||||
export const windowIdField: INodeProperties = {
|
||||
displayName: 'Window ID',
|
||||
name: 'windowId',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json["windowId"] }}',
|
||||
description:
|
||||
'The ID of the <a href="https://docs.airtop.ai/guides/how-to/creating-a-session#windows" target="_blank">Window</a> to use',
|
||||
};
|
||||
|
||||
export const profileNameField: INodeProperties = {
|
||||
displayName: 'Profile Name',
|
||||
name: 'profileName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The name of the Airtop profile to load or create',
|
||||
hint: '<a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Learn more</a> about Airtop profiles',
|
||||
placeholder: 'e.g. my-x-profile',
|
||||
};
|
||||
|
||||
export const urlField: INodeProperties = {
|
||||
displayName: 'URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. https://google.com',
|
||||
description: 'URL to load in the window',
|
||||
};
|
||||
|
||||
/**
|
||||
* Extraction related fields
|
||||
*/
|
||||
|
||||
export const outputSchemaField: INodeProperties = {
|
||||
displayName: 'JSON Output Schema',
|
||||
name: 'outputSchema',
|
||||
description: 'JSON schema defining the structure of the output',
|
||||
hint: 'If you want to force your output to be JSON, provide a valid JSON schema describing the output. You can generate one automatically in the <a href="https://portal.airtop.ai/" target="_blank">Airtop API Playground</a>.',
|
||||
type: 'json',
|
||||
default: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Interaction related fields
|
||||
*/
|
||||
|
||||
export const elementDescriptionField: INodeProperties = {
|
||||
displayName: 'Element Description',
|
||||
name: 'elementDescription',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'A specific description of the element to execute the interaction on',
|
||||
placeholder: 'e.g. the search box at the top of the page',
|
||||
};
|
||||
|
||||
export function getSessionModeFields(resource: string, operations: string[]): INodeProperties[] {
|
||||
return [
|
||||
{
|
||||
displayName: 'Session Mode',
|
||||
name: 'sessionMode',
|
||||
type: 'options',
|
||||
default: 'existing',
|
||||
description: 'Choose between creating a new session or using an existing one',
|
||||
options: [
|
||||
{
|
||||
name: 'Automatically Create Session',
|
||||
description: 'Automatically create a new session and window for this operation',
|
||||
value: SESSION_MODE.NEW,
|
||||
},
|
||||
{
|
||||
name: 'Use Existing Session',
|
||||
description: 'Use an existing session and window for this operation',
|
||||
value: SESSION_MODE.EXISTING,
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
operation: operations,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
sessionMode: [SESSION_MODE.EXISTING],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
sessionMode: [SESSION_MODE.EXISTING],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...urlField,
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
sessionMode: [SESSION_MODE.NEW],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...profileNameField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
sessionMode: [SESSION_MODE.NEW],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Auto-Terminate Session',
|
||||
name: 'autoTerminateSession',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to terminate the session after the operation is complete. When disabled, you must manually terminate the session. By default, idle sessions timeout after 10 minutes',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: [resource],
|
||||
sessionMode: [SESSION_MODE.NEW],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { INodeExecutionData, IExecuteFunctions, IDataObject } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateSessionAndWindowId,
|
||||
createSessionAndWindow,
|
||||
shouldCreateNewSession,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
/**
|
||||
* Execute the node operation. Creates and terminates a new session if needed.
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @param request - The request to execute
|
||||
* @returns The response from the request
|
||||
*/
|
||||
export async function executeRequestWithSessionManagement(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
request: {
|
||||
method: 'POST' | 'DELETE';
|
||||
path: string;
|
||||
body: IDataObject;
|
||||
},
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = shouldCreateNewSession.call(this, index)
|
||||
? await createSessionAndWindow.call(this, index)
|
||||
: validateSessionAndWindowId.call(this, index);
|
||||
|
||||
const shouldTerminateSession = this.getNodeParameter('autoTerminateSession', index, false);
|
||||
|
||||
const endpoint = request.path.replace('{sessionId}', sessionId).replace('{windowId}', windowId);
|
||||
const response = await apiRequest.call(this, request.method, endpoint, request.body);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
if (shouldTerminateSession) {
|
||||
await apiRequest.call(this, 'DELETE', `/sessions/${sessionId}`);
|
||||
this.logger.info(`[${this.getNode().name}] Session terminated.`);
|
||||
return this.helpers.returnJsonArray({ ...response });
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as getPaginated from './getPaginated.operation';
|
||||
import * as query from './query.operation';
|
||||
import * as scrape from './scrape.operation';
|
||||
import { getSessionModeFields } from '../common/fields';
|
||||
|
||||
export { getPaginated, query, scrape };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['extraction'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Query Page',
|
||||
value: 'query',
|
||||
description: 'Query a page to extract data or ask a question given the data on the page',
|
||||
action: 'Query page',
|
||||
},
|
||||
{
|
||||
name: 'Query Page with Pagination',
|
||||
value: 'getPaginated',
|
||||
description: 'Extract content from paginated or dynamically loaded pages',
|
||||
action: 'Query page with pagination',
|
||||
},
|
||||
{
|
||||
name: 'Smart Scrape',
|
||||
value: 'scrape',
|
||||
description: 'Scrape a page and return the data as markdown',
|
||||
action: 'Smart scrape page',
|
||||
},
|
||||
],
|
||||
default: 'getPaginated',
|
||||
},
|
||||
...getSessionModeFields('extraction', ['getPaginated', 'query', 'scrape']),
|
||||
...getPaginated.description,
|
||||
...query.description,
|
||||
];
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { outputSchemaField } from '../common/fields';
|
||||
import { executeRequestWithSessionManagement } from '../common/session.utils';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['extraction'],
|
||||
operation: ['getPaginated'],
|
||||
},
|
||||
},
|
||||
description: 'The prompt to extract data from the pages',
|
||||
placeholder: 'e.g. Extract all the product names and prices',
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['extraction'],
|
||||
operation: ['getPaginated'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
...outputSchemaField,
|
||||
},
|
||||
{
|
||||
displayName: 'Interaction Mode',
|
||||
name: 'interactionMode',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description: 'The strategy for interacting with the page',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
description: 'Automatically choose the most cost-effective mode',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
name: 'Accurate',
|
||||
description: 'Prioritize accuracy over cost',
|
||||
value: 'accurate',
|
||||
},
|
||||
{
|
||||
name: 'Cost Efficient',
|
||||
description: 'Minimize costs while ensuring effectiveness',
|
||||
value: 'cost-efficient',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Pagination Mode',
|
||||
name: 'paginationMode',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description: 'The pagination approach to use',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
description: 'Look for pagination links first, then try infinite scrolling',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
name: 'Paginated',
|
||||
description: 'Only use pagination links',
|
||||
value: 'paginated',
|
||||
},
|
||||
{
|
||||
name: 'Infinite Scroll',
|
||||
description: 'Scroll the page to load more content',
|
||||
value: 'infinite-scroll',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const prompt = this.getNodeParameter('prompt', index, '') as string;
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
|
||||
return await executeRequestWithSessionManagement.call(this, index, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/paginated-extraction',
|
||||
body: {
|
||||
prompt,
|
||||
configuration: {
|
||||
...additionalFields,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { outputSchemaField } from '../common/fields';
|
||||
import { executeRequestWithSessionManagement } from '../common/session.utils';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. Is there a login form in this page?',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['extraction'],
|
||||
operation: ['query'],
|
||||
},
|
||||
},
|
||||
description: 'The prompt to query the page content',
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['extraction'],
|
||||
operation: ['query'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
...outputSchemaField,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const prompt = this.getNodeParameter('prompt', index, '') as string;
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
|
||||
return await executeRequestWithSessionManagement.call(this, index, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/page-query',
|
||||
body: {
|
||||
prompt,
|
||||
configuration: {
|
||||
...additionalFields,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { executeRequestWithSessionManagement } from '../common/session.utils';
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
return await executeRequestWithSessionManagement.call(this, index, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/scrape-content',
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as click from './click.operation';
|
||||
import * as hover from './hover.operation';
|
||||
import * as type from './type.operation';
|
||||
import { sessionIdField, windowIdField } from '../common/fields';
|
||||
export { click, hover, type };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Click an Element',
|
||||
value: 'click',
|
||||
description: 'Execute a click on an element given a description',
|
||||
action: 'Click an element',
|
||||
},
|
||||
{
|
||||
name: 'Hover on an Element',
|
||||
value: 'hover',
|
||||
description: 'Execute a hover action on an element given a description',
|
||||
action: 'Hover on an element',
|
||||
},
|
||||
{
|
||||
name: 'Type',
|
||||
value: 'type',
|
||||
description: 'Execute a Type action on an element given a description',
|
||||
action: 'Type text',
|
||||
},
|
||||
],
|
||||
default: 'click',
|
||||
},
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...click.description,
|
||||
...hover.description,
|
||||
...type.description,
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Visual Scope',
|
||||
name: 'visualScope',
|
||||
type: 'options',
|
||||
default: 'auto',
|
||||
description: 'Defines the strategy for visual analysis of the current window',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto',
|
||||
description: 'Provides the simplest out-of-the-box experience for most web pages',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
name: 'Viewport',
|
||||
description: 'For analysis of the current browser view only',
|
||||
value: 'viewport',
|
||||
},
|
||||
{
|
||||
name: 'Page',
|
||||
description: 'For analysis of the entire page',
|
||||
value: 'page',
|
||||
},
|
||||
{
|
||||
name: 'Scan',
|
||||
description:
|
||||
"For a full page analysis on sites that have compatibility issues with 'Page' mode",
|
||||
value: 'scan',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Wait Until Event After Navigation',
|
||||
name: 'waitForNavigation',
|
||||
type: 'options',
|
||||
default: 'load',
|
||||
description:
|
||||
"The condition to wait for the navigation to complete after an interaction (click, type or hover). Defaults to 'Fully Loaded'.",
|
||||
options: [
|
||||
{
|
||||
name: 'Fully Loaded (Slower)',
|
||||
value: 'load',
|
||||
},
|
||||
{
|
||||
name: 'DOM Only Loaded (Faster)',
|
||||
value: 'domcontentloaded',
|
||||
},
|
||||
{
|
||||
name: 'All Network Activity Has Stopped',
|
||||
value: 'networkidle0',
|
||||
},
|
||||
{
|
||||
name: 'Most Network Activity Has Stopped',
|
||||
value: 'networkidle2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { constructInteractionRequest } from './helpers';
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { elementDescriptionField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...elementDescriptionField,
|
||||
placeholder: 'e.g. the green "save" button at the top of the page',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['click'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const elementDescription = validateRequiredStringField.call(
|
||||
this,
|
||||
index,
|
||||
'elementDescription',
|
||||
'Element Description',
|
||||
);
|
||||
|
||||
const request = constructInteractionRequest.call(this, index, {
|
||||
elementDescription,
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/click`,
|
||||
request,
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { IAirtopInteractionRequest } from '../../transport/types';
|
||||
|
||||
export function constructInteractionRequest(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
parameters: Partial<IAirtopInteractionRequest> = {},
|
||||
): IAirtopInteractionRequest {
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
const request: IAirtopInteractionRequest = {
|
||||
configuration: {},
|
||||
};
|
||||
|
||||
if (additionalFields.visualScope) {
|
||||
request.configuration.visualAnalysis = {
|
||||
scope: additionalFields.visualScope as string,
|
||||
};
|
||||
}
|
||||
|
||||
if (additionalFields.waitForNavigation) {
|
||||
request.waitForNavigation = true;
|
||||
request.configuration.waitForNavigationConfig = {
|
||||
waitUntil: additionalFields.waitForNavigation as string,
|
||||
};
|
||||
}
|
||||
|
||||
Object.assign(request, parameters);
|
||||
|
||||
return request;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { constructInteractionRequest } from './helpers';
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { elementDescriptionField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...elementDescriptionField,
|
||||
required: true,
|
||||
placeholder: 'e.g. the rounded user profile image at the top right of the page',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['hover'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const elementDescription = validateRequiredStringField.call(
|
||||
this,
|
||||
index,
|
||||
'elementDescription',
|
||||
'Element Description',
|
||||
);
|
||||
|
||||
const request = constructInteractionRequest.call(this, index, {
|
||||
elementDescription,
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/hover`,
|
||||
request,
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { constructInteractionRequest } from './helpers';
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { elementDescriptionField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Text',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['type'],
|
||||
},
|
||||
},
|
||||
description: 'The text to type into the browser window',
|
||||
placeholder: 'e.g. email@example.com',
|
||||
},
|
||||
{
|
||||
displayName: 'Press Enter Key',
|
||||
name: 'pressEnterKey',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to press the Enter key after typing the text',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['type'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...elementDescriptionField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['type'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const text = validateRequiredStringField.call(this, index, 'text', 'Text');
|
||||
const pressEnterKey = this.getNodeParameter('pressEnterKey', index) as boolean;
|
||||
const elementDescription = this.getNodeParameter('elementDescription', index) as string;
|
||||
|
||||
const request = constructInteractionRequest.call(this, index, {
|
||||
text,
|
||||
pressEnterKey,
|
||||
elementDescription,
|
||||
});
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/type`,
|
||||
request,
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
10
packages/nodes-base/nodes/Airtop/actions/node.type.ts
Normal file
10
packages/nodes-base/nodes/Airtop/actions/node.type.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
type NodeMap = {
|
||||
session: 'create' | 'save' | 'terminate';
|
||||
window: 'create' | 'close' | 'takeScreenshot' | 'load';
|
||||
extraction: 'getPaginated' | 'query' | 'scrape';
|
||||
interaction: 'click' | 'hover' | 'type';
|
||||
};
|
||||
|
||||
export type AirtopType = AllEntities<NodeMap>;
|
||||
63
packages/nodes-base/nodes/Airtop/actions/router.ts
Normal file
63
packages/nodes-base/nodes/Airtop/actions/router.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as extraction from './extraction/Extraction.resource';
|
||||
import * as interaction from './interaction/Interaction.resource';
|
||||
import type { AirtopType } from './node.type';
|
||||
import * as session from './session/Session.resource';
|
||||
import * as window from './window/Window.resource';
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const operationResult: INodeExecutionData[] = [];
|
||||
let responseData: IDataObject | IDataObject[] = [];
|
||||
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter<AirtopType>('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
const airtopNodeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as AirtopType;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
switch (airtopNodeData.resource) {
|
||||
case 'session':
|
||||
responseData = await session[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
case 'window':
|
||||
responseData = await window[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
case 'interaction':
|
||||
responseData = await interaction[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
case 'extraction':
|
||||
responseData = await extraction[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The resource "${resource}" is not supported!`,
|
||||
);
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
operationResult.push(...executionData);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
operationResult.push({
|
||||
json: this.getInputData(i)[0].json,
|
||||
error: error as NodeOperationError,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [operationResult];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as save from './save.operation';
|
||||
import * as terminate from './terminate.operation';
|
||||
|
||||
export { create, save, terminate };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Create Session',
|
||||
value: 'create',
|
||||
description: 'Create an Airtop browser session',
|
||||
action: 'Create a session',
|
||||
},
|
||||
{
|
||||
name: 'Save Profile on Termination',
|
||||
value: 'save',
|
||||
description:
|
||||
'Save in a profile changes made in your browsing session such as cookies and local storage',
|
||||
action: 'Save a profile on session termination',
|
||||
},
|
||||
{
|
||||
name: 'Terminate Session',
|
||||
value: 'terminate',
|
||||
description: 'Terminate a session',
|
||||
action: 'Terminate a session',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
...create.description,
|
||||
...save.description,
|
||||
...terminate.description,
|
||||
];
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { INTEGRATION_URL } from '../../constants';
|
||||
import {
|
||||
validateAirtopApiResponse,
|
||||
validateProfileName,
|
||||
validateProxyUrl,
|
||||
validateSaveProfileOnTermination,
|
||||
validateTimeoutMinutes,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { profileNameField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...profileNameField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Save Profile',
|
||||
name: 'saveProfileOnTermination',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to automatically save the <a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Airtop profile</a> for this session upon termination',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Idle Timeout',
|
||||
name: 'timeoutMinutes',
|
||||
type: 'number',
|
||||
default: 10,
|
||||
validateType: 'number',
|
||||
description: 'Minutes to wait before the session is terminated due to inactivity',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Proxy',
|
||||
name: 'proxy',
|
||||
type: 'options',
|
||||
default: 'none',
|
||||
description: 'Choose how to configure the proxy for this session',
|
||||
options: [
|
||||
{
|
||||
name: 'None',
|
||||
value: 'none',
|
||||
description: 'No proxy will be used',
|
||||
},
|
||||
{
|
||||
name: 'Integrated',
|
||||
value: 'integrated',
|
||||
description: 'Use Airtop-provided proxy',
|
||||
},
|
||||
{
|
||||
name: 'Custom',
|
||||
value: 'custom',
|
||||
description: 'Configure a custom proxy',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Proxy URL',
|
||||
name: 'proxyUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The URL of the proxy to use',
|
||||
displayOptions: {
|
||||
show: {
|
||||
proxy: ['custom'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const url = `${INTEGRATION_URL}/create-session`;
|
||||
|
||||
const profileName = validateProfileName.call(this, index);
|
||||
const timeoutMinutes = validateTimeoutMinutes.call(this, index);
|
||||
const saveProfileOnTermination = validateSaveProfileOnTermination.call(this, index, profileName);
|
||||
const proxyParam = this.getNodeParameter('proxy', index, 'none') as string;
|
||||
const proxyUrl = validateProxyUrl.call(this, index, proxyParam);
|
||||
|
||||
const body: IDataObject = {
|
||||
configuration: {
|
||||
profileName,
|
||||
timeoutMinutes,
|
||||
proxy: proxyParam === 'custom' ? proxyUrl : Boolean(proxyParam === 'integrated'),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', url, body);
|
||||
const sessionId = response.sessionId;
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
if (saveProfileOnTermination) {
|
||||
await apiRequest.call(
|
||||
this,
|
||||
'PUT',
|
||||
`/sessions/${sessionId}/save-profile-on-termination/${profileName}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId } as IDataObject);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateAirtopApiResponse,
|
||||
validateProfileName,
|
||||
validateRequiredStringField,
|
||||
validateSessionId,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { sessionIdField, profileNameField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName:
|
||||
"Note: This operation is not needed if you enabled 'Save Profile' in the 'Create Session' operation",
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['save'],
|
||||
},
|
||||
},
|
||||
default: 'This operation will save the profile on session termination',
|
||||
},
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['save'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...profileNameField,
|
||||
required: true,
|
||||
description:
|
||||
'The name of the <a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Profile</a> to save',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['save'],
|
||||
},
|
||||
},
|
||||
hint: 'Name of the profile you want to save. Must consist only of alphanumeric characters and hyphens "-"',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateSessionId.call(this, index);
|
||||
let profileName = validateRequiredStringField.call(this, index, 'profileName', 'Profile Name');
|
||||
profileName = validateProfileName.call(this, index);
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'PUT',
|
||||
`/sessions/${sessionId}/save-profile-on-termination/${profileName}`,
|
||||
);
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, profileName, ...response } as IDataObject);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { validateAirtopApiResponse, validateSessionId } from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { sessionIdField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['terminate'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateSessionId.call(this, index);
|
||||
const response = await apiRequest.call(this, 'DELETE', `/sessions/${sessionId}`);
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ success: true } as IDataObject);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as close from './close.operation';
|
||||
import * as create from './create.operation';
|
||||
import * as load from './load.operation';
|
||||
import * as takeScreenshot from './takeScreenshot.operation';
|
||||
import { sessionIdField, windowIdField } from '../common/fields';
|
||||
|
||||
export { create, close, takeScreenshot, load };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
sortable: false,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Create a New Browser Window',
|
||||
value: 'create',
|
||||
description: 'Create a new browser window inside a session. Can load a URL when created.',
|
||||
action: 'Create a window',
|
||||
},
|
||||
{
|
||||
name: 'Load URL',
|
||||
value: 'load',
|
||||
description: 'Load a URL in an existing window',
|
||||
action: 'Load a page',
|
||||
},
|
||||
{
|
||||
name: 'Take Screenshot',
|
||||
value: 'takeScreenshot',
|
||||
description: 'Take a screenshot of the current window',
|
||||
action: 'Take screenshot',
|
||||
},
|
||||
{
|
||||
name: 'Close Window',
|
||||
value: 'close',
|
||||
description: 'Close a window inside a session',
|
||||
action: 'Close a window',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['close', 'takeScreenshot', 'load'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...create.description,
|
||||
...load.description,
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { validateAirtopApiResponse, validateSessionAndWindowId } from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'DELETE',
|
||||
`/sessions/${sessionId}/windows/${windowId}`,
|
||||
);
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateAirtopApiResponse,
|
||||
validateSessionId,
|
||||
validateUrl,
|
||||
validateScreenResolution,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type { IAirtopResponse } from '../../transport/types';
|
||||
import { urlField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...urlField,
|
||||
description: 'Initial URL to load in the window. Defaults to https://www.google.com.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Live View Options
|
||||
{
|
||||
displayName: 'Get Live View',
|
||||
name: 'getLiveView',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to get the URL of the window\'s <a href="https://docs.airtop.ai/guides/how-to/creating-a-live-view" target="_blank">Live View</a>',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Include Navigation Bar',
|
||||
name: 'includeNavigationBar',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to include the navigation bar in the Live View. When enabled, the navigation bar will be visible allowing you to navigate between pages.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
getLiveView: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Screen Resolution',
|
||||
name: 'screenResolution',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'The screen resolution of the Live View. Setting a resolution will force the window to open at that specific size.',
|
||||
placeholder: 'e.g. 1280x720',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
getLiveView: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Disable Resize',
|
||||
name: 'disableResize',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to disable the window from being resized in the Live View',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
getLiveView: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Wait Until',
|
||||
name: 'waitUntil',
|
||||
type: 'options',
|
||||
description: 'Wait until the specified loading event occurs',
|
||||
default: 'load',
|
||||
options: [
|
||||
{
|
||||
name: 'Load',
|
||||
value: 'load',
|
||||
description: "Wait until the page dom and it's assets have loaded",
|
||||
},
|
||||
{
|
||||
name: 'DOM Content Loaded',
|
||||
value: 'domContentLoaded',
|
||||
description: 'Wait until the page DOM has loaded',
|
||||
},
|
||||
{
|
||||
name: 'Complete',
|
||||
value: 'complete',
|
||||
description: 'Wait until all iframes in the page have loaded',
|
||||
},
|
||||
{
|
||||
name: 'No Wait',
|
||||
value: 'noWait',
|
||||
description: 'Do not wait for any loading event and it will return immediately',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateSessionId.call(this, index);
|
||||
const url = validateUrl.call(this, index);
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
// Live View Options
|
||||
const getLiveView = this.getNodeParameter('getLiveView', index, false);
|
||||
const includeNavigationBar = this.getNodeParameter('includeNavigationBar', index, false);
|
||||
const screenResolution = validateScreenResolution.call(this, index);
|
||||
const disableResize = this.getNodeParameter('disableResize', index, false);
|
||||
|
||||
let response: IAirtopResponse;
|
||||
|
||||
const body: IDataObject = {
|
||||
url,
|
||||
...additionalFields,
|
||||
};
|
||||
|
||||
response = await apiRequest.call(this, 'POST', `/sessions/${sessionId}/windows`, body);
|
||||
|
||||
if (!response?.data?.windowId) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: 'Failed to create window',
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const windowId = String(response.data.windowId);
|
||||
|
||||
if (getLiveView) {
|
||||
// Get Window info
|
||||
response = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/sessions/${sessionId}/windows/${windowId}`,
|
||||
undefined,
|
||||
{
|
||||
...(includeNavigationBar && { includeNavigationBar: true }),
|
||||
...(screenResolution && { screenResolution }),
|
||||
...(disableResize && { disableResize: true }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateUrl,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { urlField } from '../common/fields';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...urlField,
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['load'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['load'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Wait Until',
|
||||
name: 'waitUntil',
|
||||
type: 'options',
|
||||
default: 'load',
|
||||
description: "Wait until the specified loading event occurs. Defaults to 'Fully Loaded'.",
|
||||
options: [
|
||||
{
|
||||
name: 'Complete',
|
||||
value: 'complete',
|
||||
description: "Wait until the page and all it's iframes have loaded it's dom and assets",
|
||||
},
|
||||
{
|
||||
name: 'DOM Only Loaded',
|
||||
value: 'domContentLoaded',
|
||||
description: 'Wait until the dom has loaded',
|
||||
},
|
||||
{
|
||||
name: 'Fully Loaded',
|
||||
value: 'load',
|
||||
description: "Wait until the page dom and it's assets have loaded",
|
||||
},
|
||||
{
|
||||
name: 'No Wait',
|
||||
value: 'noWait',
|
||||
description: 'Do not wait for any loading event and will return immediately',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
let url = validateRequiredStringField.call(this, index, 'url', 'URL');
|
||||
url = validateUrl.call(this, index);
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}`,
|
||||
{
|
||||
url,
|
||||
waitUntil: additionalFields.waitUntil,
|
||||
},
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, IBinaryData } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
convertScreenshotToBinary,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
let data: IBinaryData | undefined; // for storing the binary data
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/screenshot`,
|
||||
);
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
// process screenshot on success
|
||||
if (response.meta?.screenshots?.length) {
|
||||
const buffer = convertScreenshotToBinary(response.meta.screenshots[0]);
|
||||
data = await this.helpers.prepareBinaryData(buffer, 'screenshot.jpg', 'image/jpeg');
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
sessionId,
|
||||
windowId,
|
||||
...response,
|
||||
},
|
||||
...(data ? { binary: { data } } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
1
packages/nodes-base/nodes/Airtop/airtop.svg
Normal file
1
packages/nodes-base/nodes/Airtop/airtop.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><g clip-path="url(#b)"><circle cx="16" cy="16" r="16" fill="#102626"/><g filter="url(#c)"><path stroke="#58D1EC" stroke-opacity=".2" stroke-width="21.821" d="m-24.172-33.325 41.946 51.414"/></g><path fill="url(#d)" fill-rule="evenodd" d="M12.598 9.258c1.607-2.545 5.318-2.545 6.925 0l2.33 3.69c.705 1.115-.097 2.57-1.416 2.57-2.207 0-3.088-.841-3.66-4.182l-1.423.005c-.496 3.346-1.507 4.177-4.17 4.177l-.005 1.437c2.696 0 4.175 1.495 4.175 3.454a2.94 2.94 0 0 1-2.94 2.94h-1.285c-3.225 0-5.185-3.555-3.463-6.282l4.932-7.809zm7.125 14.092a2.945 2.945 0 0 1-2.945-2.946c0-1.954 1.305-3.449 3.659-3.449h1.443c1.479 0 3.093.924 3.093 2.746 0 2.209-2.511 3.649-3.921 3.649h-1.329z" clip-rule="evenodd"/></g></g><defs><filter id="a" width="32" height="35" x="0" y="-2" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation="1"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0.345098 0 0 0 0 0.819608 0 0 0 0 0.92549 0 0 0 0.16 0"/><feBlend in2="shape" result="effect1_innerShadow_820_10455"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="-2"/><feGaussianBlur stdDeviation="1"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0.0148985 0 0 0 0 0.0926901 0 0 0 0 0.0926901 0 0 0 0.4 0"/><feBlend in2="effect1_innerShadow_820_10455" result="effect2_innerShadow_820_10455"/></filter><filter id="c" width="74.854" height="81.209" x="-40.627" y="-48.223" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_820_10455" stdDeviation="4"/></filter><radialGradient id="d" cx="0" cy="0" r="1" gradientTransform="matrix(-11.68435 11.70476 -89.05215 -88.89686 19.28 9.557)" gradientUnits="userSpaceOnUse"><stop offset=".613" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity=".8"/></radialGradient><clipPath id="b"><rect width="32" height="32" fill="#fff" rx="16"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
21
packages/nodes-base/nodes/Airtop/constants.ts
Normal file
21
packages/nodes-base/nodes/Airtop/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const BASE_URL = 'https://api.airtop.ai/api/v1';
|
||||
export const INTEGRATION_URL = 'https://portal-api.airtop.ai/integrations/v1/no-code';
|
||||
|
||||
export const DEFAULT_TIMEOUT_MINUTES = 10;
|
||||
export const MIN_TIMEOUT_MINUTES = 1;
|
||||
export const MAX_TIMEOUT_MINUTES = 10080;
|
||||
|
||||
export const ERROR_MESSAGES = {
|
||||
SESSION_ID_REQUIRED: "Please fill the 'Session ID' parameter",
|
||||
WINDOW_ID_REQUIRED: "Please fill the 'Window ID' parameter",
|
||||
URL_REQUIRED: "Please fill the 'URL' parameter",
|
||||
PROFILE_NAME_INVALID: "'Profile Name' should only contain letters, numbers and dashes",
|
||||
TIMEOUT_MINUTES_INVALID: `Timeout must be between ${MIN_TIMEOUT_MINUTES} and ${MAX_TIMEOUT_MINUTES} minutes`,
|
||||
URL_INVALID: "'URL' must start with 'http' or 'https'",
|
||||
PROFILE_NAME_REQUIRED: "'Profile Name' is required when 'Save Profile' is enabled",
|
||||
REQUIRED_PARAMETER: "Please fill the '{{field}}' parameter",
|
||||
PROXY_URL_REQUIRED: "Please fill the 'Proxy URL' parameter",
|
||||
PROXY_URL_INVALID: "'Proxy URL' must start with 'http' or 'https'",
|
||||
SCREEN_RESOLUTION_INVALID:
|
||||
"'Screen Resolution' must be in the format 'width x height' (e.g. '1280x720')",
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import nock from 'nock';
|
||||
|
||||
import * as getPaginated from '../../../actions/extraction/getPaginated.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as GenericFunctions from '../../../GenericFunctions';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'extraction',
|
||||
operation: 'getPaginated',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
sessionMode: 'existing',
|
||||
additionalFields: {},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
data: {
|
||||
modelResponse:
|
||||
'{"items": [{"title": "Item 1", "price": "$10.99"}, {"title": "Item 2", "price": "$20.99"}]}',
|
||||
},
|
||||
};
|
||||
|
||||
const mockJsonSchema =
|
||||
'{"type":"object","properties":{"title":{"type":"string"},"price":{"type":"string"}}}';
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async (method: string, endpoint: string) => {
|
||||
// For paginated extraction requests
|
||||
if (endpoint.includes('/paginated-extraction')) {
|
||||
return mockResponse;
|
||||
}
|
||||
|
||||
// For session deletion
|
||||
if (method === 'DELETE' && endpoint.includes('/sessions/')) {
|
||||
return { status: 'success' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../GenericFunctions', () => {
|
||||
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
|
||||
return {
|
||||
...originalModule,
|
||||
createSessionAndWindow: jest.fn().mockImplementation(async () => {
|
||||
return {
|
||||
sessionId: 'new-session-123',
|
||||
windowId: 'new-window-123',
|
||||
};
|
||||
}),
|
||||
shouldCreateNewSession: jest.fn().mockImplementation(function (
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
) {
|
||||
const sessionMode = this.getNodeParameter('sessionMode', index) as string;
|
||||
return sessionMode === 'new';
|
||||
}),
|
||||
validateAirtopApiResponse: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, getPaginated operation', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
jest.unmock('../../../transport');
|
||||
jest.unmock('../../../GenericFunctions');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should extract data with minimal parameters', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
prompt: 'Extract all product titles and prices',
|
||||
};
|
||||
|
||||
const result = await getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/paginated-extraction',
|
||||
{
|
||||
prompt: 'Extract all product titles and prices',
|
||||
configuration: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract data with output schema', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
prompt: 'Extract all product titles and prices',
|
||||
additionalFields: {
|
||||
outputSchema: mockJsonSchema,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/paginated-extraction',
|
||||
{
|
||||
prompt: 'Extract all product titles and prices',
|
||||
configuration: {
|
||||
outputSchema: mockJsonSchema,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
['auto', 'accurate', 'cost-efficient'].forEach((interactionMode) => {
|
||||
it(`interactionMode > Should extract data with '${interactionMode}' mode`, async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
prompt: 'Extract all product titles and prices',
|
||||
additionalFields: {
|
||||
interactionMode,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/paginated-extraction',
|
||||
{
|
||||
prompt: 'Extract all product titles and prices',
|
||||
configuration: {
|
||||
interactionMode,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
['auto', 'paginated', 'infinite-scroll'].forEach((paginationMode) => {
|
||||
it(`paginationMode > Should extract data with '${paginationMode}' mode`, async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
prompt: 'Extract all product titles and prices',
|
||||
additionalFields: {
|
||||
paginationMode,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/paginated-extraction',
|
||||
{
|
||||
prompt: 'Extract all product titles and prices',
|
||||
configuration: {
|
||||
paginationMode,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract data using a new session', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
sessionMode: 'new',
|
||||
autoTerminateSession: true,
|
||||
url: 'https://example.com',
|
||||
prompt: 'Extract all product titles and prices',
|
||||
};
|
||||
|
||||
const result = await getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2); // One for extraction, one for session deletion
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/sessions/new-session-123/windows/new-window-123/paginated-extraction',
|
||||
{
|
||||
prompt: 'Extract all product titles and prices',
|
||||
configuration: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when 'sessionId' is empty and session mode is 'existing'", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
sessionId: '',
|
||||
prompt: 'Extract data',
|
||||
};
|
||||
|
||||
await expect(
|
||||
getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).rejects.toThrow(ERROR_MESSAGES.SESSION_ID_REQUIRED);
|
||||
});
|
||||
|
||||
it("should throw error when 'windowId' is empty and session mode is 'existing'", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
windowId: '',
|
||||
prompt: 'Extract data',
|
||||
};
|
||||
|
||||
await expect(
|
||||
getPaginated.execute.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).rejects.toThrow(ERROR_MESSAGES.WINDOW_ID_REQUIRED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import * as query from '../../../actions/extraction/query.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as GenericFunctions from '../../../GenericFunctions';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'extraction',
|
||||
operation: 'query',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
sessionMode: 'existing',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
data: {
|
||||
modelResponse: {
|
||||
answer: 'The page contains 5 products with prices ranging from $10.99 to $50.99',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockJsonSchema =
|
||||
'{"type":"object","properties":{"productCount":{"type":"number"},"priceRange":{"type":"object"}}}';
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function (method: string, endpoint: string) {
|
||||
if (method === 'DELETE' && endpoint.includes('/sessions/')) {
|
||||
return { status: 'success' };
|
||||
}
|
||||
return mockResponse;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../GenericFunctions', () => {
|
||||
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
|
||||
return {
|
||||
...originalModule,
|
||||
createSessionAndWindow: jest.fn().mockImplementation(async () => {
|
||||
return {
|
||||
sessionId: 'new-session-456',
|
||||
windowId: 'new-win-456',
|
||||
};
|
||||
}),
|
||||
shouldCreateNewSession: jest.fn().mockImplementation(function (this: any) {
|
||||
const sessionMode = this.getNodeParameter('sessionMode', 0);
|
||||
return sessionMode === 'new';
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, query page operation', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
jest.unmock('../../../transport');
|
||||
jest.unmock('../../../GenericFunctions');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should query the page with minimal parameters using existing session', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
prompt: 'How many products are on the page and what is their price range?',
|
||||
};
|
||||
|
||||
const result = await query.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/page-query',
|
||||
{
|
||||
prompt: 'How many products are on the page and what is their price range?',
|
||||
configuration: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should query the page with output schema using existing session', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
prompt: 'How many products are on the page and what is their price range?',
|
||||
additionalFields: {
|
||||
outputSchema: mockJsonSchema,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await query.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/page-query',
|
||||
{
|
||||
prompt: 'How many products are on the page and what is their price range?',
|
||||
configuration: {
|
||||
outputSchema: mockJsonSchema,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should query the page using a new session', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
sessionMode: 'new',
|
||||
url: 'https://example.com',
|
||||
prompt: 'How many products are on the page and what is their price range?',
|
||||
autoTerminateSession: true,
|
||||
};
|
||||
|
||||
const result = await query.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2); // One for query, one for session deletion
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/sessions/new-session-456/windows/new-win-456/page-query',
|
||||
{
|
||||
prompt: 'How many products are on the page and what is their price range?',
|
||||
configuration: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when 'sessionId' is empty in 'existing' session mode", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
sessionId: '',
|
||||
prompt: 'Query data',
|
||||
};
|
||||
|
||||
await expect(query.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.SESSION_ID_REQUIRED,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when 'windowId' is empty in 'existing' session mode", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
windowId: '',
|
||||
prompt: 'Query data',
|
||||
};
|
||||
|
||||
await expect(query.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.WINDOW_ID_REQUIRED,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import * as scrape from '../../../actions/extraction/scrape.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as GenericFunctions from '../../../GenericFunctions';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'extraction',
|
||||
operation: 'scrape',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
sessionMode: 'existing',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
data: {
|
||||
content: '<html><body>Scraped content</body></html>',
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function (method: string, endpoint: string) {
|
||||
if (method === 'DELETE' && endpoint.includes('/sessions/')) {
|
||||
return { status: 'success' };
|
||||
}
|
||||
return mockResponse;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../GenericFunctions', () => {
|
||||
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
|
||||
return {
|
||||
...originalModule,
|
||||
createSessionAndWindow: jest.fn().mockImplementation(async () => {
|
||||
return {
|
||||
sessionId: 'new-session-456',
|
||||
windowId: 'new-win-456',
|
||||
};
|
||||
}),
|
||||
shouldCreateNewSession: jest.fn().mockImplementation(function (this: any) {
|
||||
const sessionMode = this.getNodeParameter('sessionMode', 0);
|
||||
return sessionMode === 'new';
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, scrape operation', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
jest.unmock('../../../transport');
|
||||
jest.unmock('../../../GenericFunctions');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should scrape content with minimal parameters using existing session', async () => {
|
||||
const result = await scrape.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/scrape-content',
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should scrape content with additional parameters using existing session', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
additionalFields: {
|
||||
waitForSelector: '.product-list',
|
||||
waitForTimeout: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await scrape.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/scrape-content',
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should scrape content using a new session', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
sessionMode: 'new',
|
||||
url: 'https://example.com',
|
||||
autoTerminateSession: true,
|
||||
};
|
||||
|
||||
const result = await scrape.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2); // One for scrape, one for session deletion
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/sessions/new-session-456/windows/new-win-456/scrape-content',
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when sessionId is empty in 'existing' session mode", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
sessionId: '',
|
||||
};
|
||||
|
||||
await expect(scrape.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.SESSION_ID_REQUIRED,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when windowId is empty in 'existing' session mode", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
windowId: '',
|
||||
};
|
||||
|
||||
await expect(scrape.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.WINDOW_ID_REQUIRED,
|
||||
);
|
||||
});
|
||||
});
|
||||
57
packages/nodes-base/nodes/Airtop/test/node/helpers.ts
Normal file
57
packages/nodes-base/nodes/Airtop/test/node/helpers.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { get } from 'lodash';
|
||||
import { constructExecutionMetaData } from 'n8n-core';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export const node: INode = {
|
||||
id: '1',
|
||||
name: 'Airtop node',
|
||||
typeVersion: 1,
|
||||
type: 'n8n-nodes-base.airtop',
|
||||
position: [10, 10],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
export const createMockExecuteFunction = (nodeParameters: IDataObject) => {
|
||||
const fakeExecuteFunction = {
|
||||
getInputData(): INodeExecutionData[] {
|
||||
return [{ json: {} }];
|
||||
},
|
||||
getNodeParameter(
|
||||
parameterName: string,
|
||||
_itemIndex: number,
|
||||
fallbackValue?: IDataObject | undefined,
|
||||
options?: IGetNodeParameterOptions | undefined,
|
||||
) {
|
||||
const parameter = options?.extractValue ? `${parameterName}.value` : parameterName;
|
||||
return get(nodeParameters, parameter, fallbackValue);
|
||||
},
|
||||
getNode() {
|
||||
return node;
|
||||
},
|
||||
helpers: {
|
||||
constructExecutionMetaData,
|
||||
returnJsonArray: (data: IDataObject | IDataObject[]) => {
|
||||
return [{ json: data }] as INodeExecutionData[];
|
||||
},
|
||||
prepareBinaryData: async (data: Buffer) => {
|
||||
return {
|
||||
mimeType: 'image/jpeg',
|
||||
fileType: 'jpg',
|
||||
fileName: 'screenshot.jpg',
|
||||
data: data.toString('base64'),
|
||||
};
|
||||
},
|
||||
},
|
||||
continueOnFail: () => false,
|
||||
logger: {
|
||||
info: () => {},
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
return fakeExecuteFunction;
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import * as click from '../../../actions/interaction/click.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'interaction',
|
||||
operation: 'click',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
elementDescription: 'the login button',
|
||||
additionalFields: {},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
message: 'Click executed successfully',
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {
|
||||
status: 'success',
|
||||
data: mockResponse,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, click operation', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should execute click with minimal parameters', async () => {
|
||||
const result = await click.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/click',
|
||||
{
|
||||
elementDescription: 'the login button',
|
||||
configuration: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: baseNodeParameters.windowId,
|
||||
status: 'success',
|
||||
data: mockResponse,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when 'elementDescription' parameter is empty", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
elementDescription: '',
|
||||
};
|
||||
const errorMessage = ERROR_MESSAGES.REQUIRED_PARAMETER.replace(
|
||||
'{{field}}',
|
||||
'Element Description',
|
||||
);
|
||||
|
||||
await expect(click.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
errorMessage,
|
||||
);
|
||||
});
|
||||
|
||||
it("should include 'visualScope' parameter when specified", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
additionalFields: {
|
||||
visualScope: 'viewport',
|
||||
},
|
||||
};
|
||||
|
||||
await click.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/click',
|
||||
{
|
||||
configuration: {
|
||||
visualAnalysis: {
|
||||
scope: 'viewport',
|
||||
},
|
||||
},
|
||||
elementDescription: 'the login button',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should include 'waitForNavigation' parameter when specified", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
additionalFields: {
|
||||
waitForNavigation: 'load',
|
||||
},
|
||||
};
|
||||
|
||||
await click.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/click',
|
||||
{
|
||||
configuration: {
|
||||
waitForNavigationConfig: {
|
||||
waitUntil: 'load',
|
||||
},
|
||||
},
|
||||
waitForNavigation: true,
|
||||
elementDescription: 'the login button',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { constructInteractionRequest } from '../../../actions/interaction/helpers';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
describe('Test Airtop interaction helpers', () => {
|
||||
describe('constructInteractionRequest', () => {
|
||||
it('should construct basic request with default values', () => {
|
||||
const mockExecute = createMockExecuteFunction({
|
||||
additionalFields: {},
|
||||
});
|
||||
|
||||
const request = constructInteractionRequest.call(mockExecute, 0);
|
||||
|
||||
expect(request).toEqual({
|
||||
configuration: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should include 'visualScope' parameter when specified", () => {
|
||||
const mockExecute = createMockExecuteFunction({
|
||||
additionalFields: {
|
||||
visualScope: 'viewport',
|
||||
},
|
||||
});
|
||||
|
||||
const request = constructInteractionRequest.call(mockExecute, 0);
|
||||
|
||||
expect(request).toEqual({
|
||||
configuration: {
|
||||
visualAnalysis: {
|
||||
scope: 'viewport',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should include 'waitForNavigation' parameter when specified", () => {
|
||||
const mockExecute = createMockExecuteFunction({
|
||||
additionalFields: {
|
||||
waitForNavigation: 'load',
|
||||
},
|
||||
});
|
||||
|
||||
const request = constructInteractionRequest.call(mockExecute, 0);
|
||||
|
||||
expect(request).toEqual({
|
||||
configuration: {
|
||||
waitForNavigationConfig: {
|
||||
waitUntil: 'load',
|
||||
},
|
||||
},
|
||||
waitForNavigation: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge additional parameters', () => {
|
||||
const mockExecute = createMockExecuteFunction({
|
||||
additionalFields: {
|
||||
waitForNavigation: 'load',
|
||||
},
|
||||
});
|
||||
|
||||
const request = constructInteractionRequest.call(mockExecute, 0, {
|
||||
elementDescription: 'test element',
|
||||
});
|
||||
|
||||
expect(request).toEqual({
|
||||
configuration: {
|
||||
waitForNavigationConfig: {
|
||||
waitUntil: 'load',
|
||||
},
|
||||
},
|
||||
waitForNavigation: true,
|
||||
elementDescription: 'test element',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import * as hover from '../../../actions/interaction/hover.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'interaction',
|
||||
operation: 'hover',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
elementDescription: 'the user profile image',
|
||||
additionalFields: {},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
message: 'Hover interaction executed successfully',
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {
|
||||
status: 'success',
|
||||
data: mockResponse,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, hover operation', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should execute hover with minimal parameters', async () => {
|
||||
const result = await hover.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/hover',
|
||||
{
|
||||
configuration: {},
|
||||
elementDescription: 'the user profile image',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
status: 'success',
|
||||
data: mockResponse,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when 'elementDescription' parameter is empty", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
elementDescription: '',
|
||||
};
|
||||
const errorMessage = ERROR_MESSAGES.REQUIRED_PARAMETER.replace(
|
||||
'{{field}}',
|
||||
'Element Description',
|
||||
);
|
||||
|
||||
await expect(hover.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
errorMessage,
|
||||
);
|
||||
});
|
||||
|
||||
it("should include 'visualScope' parameter when specified", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
additionalFields: {
|
||||
visualScope: 'viewport',
|
||||
},
|
||||
};
|
||||
|
||||
await hover.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/hover',
|
||||
{
|
||||
configuration: {
|
||||
visualAnalysis: {
|
||||
scope: 'viewport',
|
||||
},
|
||||
},
|
||||
elementDescription: 'the user profile image',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should include 'waitForNavigation' parameter when specified", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
additionalFields: {
|
||||
waitForNavigation: 'load',
|
||||
},
|
||||
};
|
||||
|
||||
await hover.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/hover',
|
||||
{
|
||||
configuration: {
|
||||
waitForNavigationConfig: {
|
||||
waitUntil: 'load',
|
||||
},
|
||||
},
|
||||
waitForNavigation: true,
|
||||
elementDescription: 'the user profile image',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import * as type from '../../../actions/interaction/type.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'interaction',
|
||||
operation: 'type',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
text: 'Hello World',
|
||||
pressEnterKey: false,
|
||||
elementDescription: '',
|
||||
additionalFields: {},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
message: 'Text typed successfully',
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {
|
||||
status: 'success',
|
||||
data: mockResponse,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, type operation', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should execute type with minimal parameters', async () => {
|
||||
const result = await type.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/type',
|
||||
{
|
||||
configuration: {},
|
||||
text: 'Hello World',
|
||||
pressEnterKey: false,
|
||||
elementDescription: '',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: baseNodeParameters.windowId,
|
||||
status: 'success',
|
||||
data: mockResponse,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when 'text' parameter is empty", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
text: '',
|
||||
};
|
||||
|
||||
await expect(type.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Text'),
|
||||
);
|
||||
});
|
||||
|
||||
it("should include 'elementDescription' parameter when specified", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
elementDescription: 'the search box',
|
||||
};
|
||||
|
||||
await type.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/type',
|
||||
{
|
||||
configuration: {},
|
||||
text: 'Hello World',
|
||||
pressEnterKey: false,
|
||||
elementDescription: 'the search box',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should include 'pressEnterKey' parameter when specified", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
pressEnterKey: true,
|
||||
};
|
||||
|
||||
await type.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/type',
|
||||
{
|
||||
configuration: {},
|
||||
text: 'Hello World',
|
||||
pressEnterKey: true,
|
||||
elementDescription: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should include 'waitForNavigation' parameter when specified", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
additionalFields: {
|
||||
waitForNavigation: 'load',
|
||||
},
|
||||
};
|
||||
|
||||
await type.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/type',
|
||||
{
|
||||
configuration: {
|
||||
waitForNavigationConfig: {
|
||||
waitUntil: 'load',
|
||||
},
|
||||
},
|
||||
waitForNavigation: true,
|
||||
text: 'Hello World',
|
||||
pressEnterKey: false,
|
||||
elementDescription: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should include 'visualScope' parameter when specified", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
additionalFields: {
|
||||
visualScope: 'viewport',
|
||||
},
|
||||
};
|
||||
|
||||
await type.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/type',
|
||||
{
|
||||
configuration: {
|
||||
visualAnalysis: {
|
||||
scope: 'viewport',
|
||||
},
|
||||
},
|
||||
text: 'Hello World',
|
||||
pressEnterKey: false,
|
||||
elementDescription: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import * as create from '../../../actions/session/create.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {
|
||||
sessionId: 'test-session-123',
|
||||
status: 'success',
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, session create operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a session with minimal parameters', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'create',
|
||||
profileName: 'test-profile',
|
||||
timeoutMinutes: 10,
|
||||
saveProfileOnTermination: false,
|
||||
proxy: 'none',
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'https://portal-api.airtop.ai/integrations/v1/no-code/create-session',
|
||||
{
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
timeoutMinutes: 10,
|
||||
proxy: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a session with save profile enabled', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'create',
|
||||
profileName: 'test-profile',
|
||||
timeoutMinutes: 15,
|
||||
saveProfileOnTermination: true,
|
||||
proxy: 'none',
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'https://portal-api.airtop.ai/integrations/v1/no-code/create-session',
|
||||
{
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
timeoutMinutes: 15,
|
||||
proxy: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'PUT',
|
||||
'/sessions/test-session-123/save-profile-on-termination/test-profile',
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a session with integrated proxy', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'create',
|
||||
profileName: 'test-profile',
|
||||
timeoutMinutes: 10,
|
||||
saveProfileOnTermination: false,
|
||||
proxy: 'integrated',
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'https://portal-api.airtop.ai/integrations/v1/no-code/create-session',
|
||||
{
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
timeoutMinutes: 10,
|
||||
proxy: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a session with custom proxy', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'create',
|
||||
profileName: 'test-profile',
|
||||
timeoutMinutes: 10,
|
||||
saveProfileOnTermination: false,
|
||||
proxy: 'custom',
|
||||
proxyUrl: 'http://proxy.example.com:8080',
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'https://portal-api.airtop.ai/integrations/v1/no-code/create-session',
|
||||
{
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
timeoutMinutes: 10,
|
||||
proxy: 'http://proxy.example.com:8080',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when custom proxy URL is invalid', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'create',
|
||||
profileName: 'test-profile',
|
||||
timeoutMinutes: 10,
|
||||
saveProfileOnTermination: false,
|
||||
proxy: 'custom',
|
||||
proxyUrl: 'invalid-url',
|
||||
};
|
||||
|
||||
await expect(create.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.PROXY_URL_INVALID,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when custom proxy URL is empty', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'create',
|
||||
profileName: 'test-profile',
|
||||
timeoutMinutes: 10,
|
||||
saveProfileOnTermination: false,
|
||||
proxy: 'custom',
|
||||
proxyUrl: '',
|
||||
};
|
||||
|
||||
await expect(create.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.PROXY_URL_REQUIRED,
|
||||
);
|
||||
});
|
||||
});
|
||||
103
packages/nodes-base/nodes/Airtop/test/node/session/save.test.ts
Normal file
103
packages/nodes-base/nodes/Airtop/test/node/session/save.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as save from '../../../actions/session/save.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Profile will be saved on session termination',
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const baseParameters = {
|
||||
resource: 'session',
|
||||
operation: 'save',
|
||||
sessionId: 'test-session-123',
|
||||
profileName: 'test-profile',
|
||||
};
|
||||
|
||||
describe('Test Airtop, session save operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should save a profile on session termination successfully', async () => {
|
||||
const nodeParameters = {
|
||||
...baseParameters,
|
||||
};
|
||||
|
||||
const result = await save.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/sessions/test-session-123/save-profile-on-termination/test-profile',
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
profileName: 'test-profile',
|
||||
status: 'success',
|
||||
message: 'Profile will be saved on session termination',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when sessionId is empty', async () => {
|
||||
const nodeParameters = {
|
||||
...baseParameters,
|
||||
sessionId: '',
|
||||
};
|
||||
|
||||
await expect(save.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.SESSION_ID_REQUIRED,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when sessionId is whitespace', async () => {
|
||||
const nodeParameters = {
|
||||
...baseParameters,
|
||||
sessionId: ' ',
|
||||
};
|
||||
|
||||
await expect(save.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.SESSION_ID_REQUIRED,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when profileName is empty', async () => {
|
||||
const nodeParameters = {
|
||||
...baseParameters,
|
||||
profileName: '',
|
||||
};
|
||||
|
||||
await expect(save.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
"Please fill the 'Profile Name' parameter",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when profileName is whitespace', async () => {
|
||||
const nodeParameters = {
|
||||
...baseParameters,
|
||||
profileName: ' ',
|
||||
};
|
||||
|
||||
await expect(save.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
"Please fill the 'Profile Name' parameter",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as terminate from '../../../actions/session/terminate.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {
|
||||
status: 'success',
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, session terminate operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should terminate a session successfully', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'terminate',
|
||||
sessionId: 'test-session-123',
|
||||
};
|
||||
|
||||
const result = await terminate.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('DELETE', '/sessions/test-session-123');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when sessionId is empty', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'terminate',
|
||||
sessionId: '',
|
||||
};
|
||||
|
||||
await expect(
|
||||
terminate.execute.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).rejects.toThrow(ERROR_MESSAGES.SESSION_ID_REQUIRED);
|
||||
});
|
||||
|
||||
it('should throw error when sessionId is whitespace', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'terminate',
|
||||
sessionId: ' ',
|
||||
};
|
||||
|
||||
await expect(
|
||||
terminate.execute.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).rejects.toThrow(ERROR_MESSAGES.SESSION_ID_REQUIRED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import * as close from '../../../actions/window/close.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
closed: true,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, window close operation', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should close a window successfully', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'window',
|
||||
operation: 'close',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
};
|
||||
|
||||
const result = await close.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'DELETE',
|
||||
'/sessions/test-session-123/windows/win-123',
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
status: 'success',
|
||||
data: {
|
||||
closed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when sessionId is empty', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'window',
|
||||
operation: 'close',
|
||||
sessionId: '',
|
||||
windowId: 'win-123',
|
||||
};
|
||||
|
||||
await expect(close.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.SESSION_ID_REQUIRED,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when windowId is empty', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'window',
|
||||
operation: 'close',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: '',
|
||||
};
|
||||
|
||||
await expect(close.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.WINDOW_ID_REQUIRED,
|
||||
);
|
||||
});
|
||||
});
|
||||
308
packages/nodes-base/nodes/Airtop/test/node/window/create.test.ts
Normal file
308
packages/nodes-base/nodes/Airtop/test/node/window/create.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import * as create from '../../../actions/window/create.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'window',
|
||||
operation: 'create',
|
||||
sessionId: 'test-session-123',
|
||||
url: 'https://example.com',
|
||||
getLiveView: false,
|
||||
disableResize: false,
|
||||
additionalFields: {},
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function (method: string) {
|
||||
if (method === 'GET') {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
liveViewUrl: 'https://live.airtop.ai/123-abcd',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
windowId: 'win-123',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, window create operation', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a window with minimal parameters', async () => {
|
||||
const result = await create.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows',
|
||||
{
|
||||
url: 'https://example.com',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: 'win-123',
|
||||
status: 'success',
|
||||
data: {
|
||||
windowId: 'win-123',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a window with live view', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
getLiveView: true,
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows',
|
||||
{
|
||||
url: 'https://example.com',
|
||||
},
|
||||
);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'GET',
|
||||
'/sessions/test-session-123/windows/win-123',
|
||||
undefined,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: 'win-123',
|
||||
status: 'success',
|
||||
data: {
|
||||
liveViewUrl: 'https://live.airtop.ai/123-abcd',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a window with live view and disabled resize', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
getLiveView: true,
|
||||
disableResize: true,
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows',
|
||||
{
|
||||
url: 'https://example.com',
|
||||
},
|
||||
);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'GET',
|
||||
'/sessions/test-session-123/windows/win-123',
|
||||
undefined,
|
||||
{ disableResize: true },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: 'win-123',
|
||||
status: 'success',
|
||||
data: {
|
||||
liveViewUrl: 'https://live.airtop.ai/123-abcd',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a window with live view and navigation bar', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
getLiveView: true,
|
||||
includeNavigationBar: true,
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows',
|
||||
{
|
||||
url: 'https://example.com',
|
||||
},
|
||||
);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'GET',
|
||||
'/sessions/test-session-123/windows/win-123',
|
||||
undefined,
|
||||
{ includeNavigationBar: true },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: 'win-123',
|
||||
status: 'success',
|
||||
data: {
|
||||
liveViewUrl: 'https://live.airtop.ai/123-abcd',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a window with live view and screen resolution', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
getLiveView: true,
|
||||
screenResolution: '1280x720',
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows',
|
||||
{
|
||||
url: 'https://example.com',
|
||||
},
|
||||
);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'GET',
|
||||
'/sessions/test-session-123/windows/win-123',
|
||||
undefined,
|
||||
{ screenResolution: '1280x720' },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: 'win-123',
|
||||
status: 'success',
|
||||
data: {
|
||||
liveViewUrl: 'https://live.airtop.ai/123-abcd',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a window with all live view options', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
getLiveView: true,
|
||||
includeNavigationBar: true,
|
||||
screenResolution: '1920x1080',
|
||||
disableResize: true,
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows',
|
||||
{
|
||||
url: 'https://example.com',
|
||||
},
|
||||
);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'GET',
|
||||
'/sessions/test-session-123/windows/win-123',
|
||||
undefined,
|
||||
{
|
||||
includeNavigationBar: true,
|
||||
screenResolution: '1920x1080',
|
||||
disableResize: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: 'win-123',
|
||||
status: 'success',
|
||||
data: {
|
||||
liveViewUrl: 'https://live.airtop.ai/123-abcd',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when 'sessionId' parameter is empty", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
sessionId: '',
|
||||
};
|
||||
|
||||
await expect(create.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.SESSION_ID_REQUIRED,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when screen resolution format is invalid', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
getLiveView: true,
|
||||
screenResolution: 'invalid-format',
|
||||
};
|
||||
|
||||
await expect(create.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.SCREEN_RESOLUTION_INVALID,
|
||||
);
|
||||
});
|
||||
});
|
||||
115
packages/nodes-base/nodes/Airtop/test/node/window/load.test.ts
Normal file
115
packages/nodes-base/nodes/Airtop/test/node/window/load.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import * as load from '../../../actions/window/load.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'window',
|
||||
operation: 'load',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
url: 'https://example.com',
|
||||
additionalFields: {},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
message: 'Page loaded successfully',
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {
|
||||
status: 'success',
|
||||
data: mockResponse,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, window load operation', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should load URL with minimal parameters', async () => {
|
||||
const result = await load.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123',
|
||||
{
|
||||
url: 'https://example.com',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: baseNodeParameters.windowId,
|
||||
status: 'success',
|
||||
data: mockResponse,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when URL is empty', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
url: '',
|
||||
};
|
||||
const errorMessage = ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'URL');
|
||||
|
||||
await expect(load.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
errorMessage,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when URL is invalid', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
url: 'not-a-valid-url',
|
||||
};
|
||||
|
||||
await expect(load.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.URL_INVALID,
|
||||
);
|
||||
});
|
||||
|
||||
it("should include 'waitUntil' parameter when specified", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
additionalFields: {
|
||||
waitUntil: 'domContentLoaded',
|
||||
},
|
||||
};
|
||||
|
||||
await load.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123',
|
||||
{
|
||||
url: 'https://example.com',
|
||||
waitUntil: 'domContentLoaded',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as takeScreenshot from '../../../actions/window/takeScreenshot.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as GenericFunctions from '../../../GenericFunctions';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'window',
|
||||
operation: 'takeScreenshot',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
meta: {
|
||||
screenshots: [{ dataUrl: 'base64-encoded-image-data' }],
|
||||
},
|
||||
};
|
||||
|
||||
const mockBinaryBuffer = Buffer.from('mock-binary-data');
|
||||
|
||||
const expectedBinaryResult = {
|
||||
binary: {
|
||||
data: {
|
||||
mimeType: 'image/jpeg',
|
||||
fileType: 'jpg',
|
||||
fileName: 'screenshot.jpg',
|
||||
data: mockBinaryBuffer.toString('base64'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {
|
||||
status: 'success',
|
||||
...mockResponse,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../GenericFunctions', () => {
|
||||
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
|
||||
return {
|
||||
...originalModule,
|
||||
convertScreenshotToBinary: jest.fn(() => mockBinaryBuffer),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, take screenshot operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
jest.unmock('../../../GenericFunctions');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should take screenshot successfully', async () => {
|
||||
const result = await takeScreenshot.execute.call(
|
||||
createMockExecuteFunction({ ...baseNodeParameters }),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/screenshot',
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
status: 'success',
|
||||
...mockResponse,
|
||||
},
|
||||
...expectedBinaryResult,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should transform screenshot to binary data', async () => {
|
||||
const result = await takeScreenshot.execute.call(
|
||||
createMockExecuteFunction({
|
||||
...baseNodeParameters,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/screenshot',
|
||||
);
|
||||
|
||||
expect(GenericFunctions.convertScreenshotToBinary).toHaveBeenCalledWith(
|
||||
mockResponse.meta.screenshots[0],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
status: 'success',
|
||||
...mockResponse,
|
||||
},
|
||||
...expectedBinaryResult,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when sessionId is empty', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
sessionId: '',
|
||||
};
|
||||
|
||||
await expect(
|
||||
takeScreenshot.execute.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).rejects.toThrow(ERROR_MESSAGES.SESSION_ID_REQUIRED);
|
||||
});
|
||||
|
||||
it('should throw error when windowId is empty', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
windowId: '',
|
||||
};
|
||||
|
||||
await expect(
|
||||
takeScreenshot.execute.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).rejects.toThrow(ERROR_MESSAGES.WINDOW_ID_REQUIRED);
|
||||
});
|
||||
});
|
||||
201
packages/nodes-base/nodes/Airtop/test/session-utils.test.ts
Normal file
201
packages/nodes-base/nodes/Airtop/test/session-utils.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { createMockExecuteFunction } from './node/helpers';
|
||||
import { SESSION_MODE } from '../actions/common/fields';
|
||||
import { executeRequestWithSessionManagement } from '../actions/common/session.utils';
|
||||
import * as transport from '../transport';
|
||||
|
||||
jest.mock('../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async () => {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../GenericFunctions', () => ({
|
||||
shouldCreateNewSession: jest.fn(function (this: IExecuteFunctions, index: number) {
|
||||
const sessionMode = this.getNodeParameter('sessionMode', index);
|
||||
return sessionMode === SESSION_MODE.NEW;
|
||||
}),
|
||||
createSessionAndWindow: jest.fn(async () => ({
|
||||
sessionId: 'new-session-123',
|
||||
windowId: 'new-window-123',
|
||||
})),
|
||||
validateSessionAndWindowId: jest.fn(() => ({
|
||||
sessionId: 'existing-session-123',
|
||||
windowId: 'existing-window-123',
|
||||
})),
|
||||
validateAirtopApiResponse: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('executeRequestWithSessionManagement', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("When 'sessionMode' is 'new'", () => {
|
||||
it("should create a new session and window when 'sessionMode' is 'new'", async () => {
|
||||
const nodeParameters = {
|
||||
sessionMode: SESSION_MODE.NEW,
|
||||
url: 'https://example.com',
|
||||
autoTerminateSession: true,
|
||||
};
|
||||
|
||||
const result = await executeRequestWithSessionManagement.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
0,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/action',
|
||||
body: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not terminate session when 'autoTerminateSession' is false", async () => {
|
||||
const nodeParameters = {
|
||||
sessionMode: SESSION_MODE.NEW,
|
||||
url: 'https://example.com',
|
||||
autoTerminateSession: false,
|
||||
};
|
||||
|
||||
const result = await executeRequestWithSessionManagement.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
0,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/action',
|
||||
body: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).not.toHaveBeenCalledWith(
|
||||
'DELETE',
|
||||
'/sessions/existing-session-123',
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'new-session-123',
|
||||
windowId: 'new-window-123',
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should terminate session when 'autoTerminateSession' is true", async () => {
|
||||
const nodeParameters = {
|
||||
sessionMode: SESSION_MODE.NEW,
|
||||
url: 'https://example.com',
|
||||
autoTerminateSession: true,
|
||||
};
|
||||
|
||||
await executeRequestWithSessionManagement.call(createMockExecuteFunction(nodeParameters), 0, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/action',
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'DELETE',
|
||||
'/sessions/new-session-123',
|
||||
);
|
||||
});
|
||||
|
||||
it("should call the operation passed in the 'request' parameter", async () => {
|
||||
const nodeParameters = {
|
||||
sessionMode: SESSION_MODE.NEW,
|
||||
url: 'https://example.com',
|
||||
autoTerminateSession: true,
|
||||
};
|
||||
|
||||
await executeRequestWithSessionManagement.call(createMockExecuteFunction(nodeParameters), 0, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/action',
|
||||
body: {
|
||||
operation: 'test-operation',
|
||||
},
|
||||
});
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/sessions/new-session-123/windows/new-window-123/action',
|
||||
{
|
||||
operation: 'test-operation',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("When 'sessionMode' is 'existing'", () => {
|
||||
it('should not create a new session and window', async () => {
|
||||
const nodeParameters = {
|
||||
sessionMode: SESSION_MODE.EXISTING,
|
||||
url: 'https://example.com',
|
||||
sessionId: 'existing-session-123',
|
||||
windowId: 'existing-window-123',
|
||||
};
|
||||
|
||||
await executeRequestWithSessionManagement.call(createMockExecuteFunction(nodeParameters), 0, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/action',
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/existing-session-123/windows/existing-window-123/action',
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("should call the operation passed in the 'request' parameter", async () => {
|
||||
const nodeParameters = {
|
||||
sessionMode: SESSION_MODE.EXISTING,
|
||||
url: 'https://example.com',
|
||||
sessionId: 'existing-session-123',
|
||||
windowId: 'existing-window-123',
|
||||
};
|
||||
|
||||
await executeRequestWithSessionManagement.call(createMockExecuteFunction(nodeParameters), 0, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/action',
|
||||
body: {
|
||||
operation: 'test-operation',
|
||||
},
|
||||
});
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/sessions/existing-session-123/windows/existing-window-123/action',
|
||||
{
|
||||
operation: 'test-operation',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
435
packages/nodes-base/nodes/Airtop/test/utils.test.ts
Normal file
435
packages/nodes-base/nodes/Airtop/test/utils.test.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { createMockExecuteFunction } from './node/helpers';
|
||||
import { ERROR_MESSAGES } from '../constants';
|
||||
import {
|
||||
createSessionAndWindow,
|
||||
validateProfileName,
|
||||
validateTimeoutMinutes,
|
||||
validateSaveProfileOnTermination,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
validateSessionId,
|
||||
validateUrl,
|
||||
validateRequiredStringField,
|
||||
shouldCreateNewSession,
|
||||
convertScreenshotToBinary,
|
||||
} from '../GenericFunctions';
|
||||
import type * as transport from '../transport';
|
||||
|
||||
jest.mock('../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async (method: string, endpoint: string) => {
|
||||
// create session
|
||||
if (endpoint.includes('/create-session')) {
|
||||
return { sessionId: 'new-session-123' };
|
||||
}
|
||||
|
||||
// create window
|
||||
if (method === 'POST' && endpoint.endsWith('/windows')) {
|
||||
return { data: { windowId: 'new-window-123' } };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test convertScreenshotToBinary', () => {
|
||||
it('should convert base64 screenshot data to buffer', () => {
|
||||
const mockScreenshot = {
|
||||
dataUrl: 'data:image/jpeg;base64,SGVsbG8gV29ybGQ=', // "Hello World" in base64
|
||||
};
|
||||
|
||||
const result = convertScreenshotToBinary(mockScreenshot);
|
||||
|
||||
expect(Buffer.isBuffer(result)).toBe(true);
|
||||
expect(result.toString()).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should handle empty base64 data', () => {
|
||||
const mockScreenshot = {
|
||||
dataUrl: 'data:image/jpeg;base64,',
|
||||
};
|
||||
|
||||
const result = convertScreenshotToBinary(mockScreenshot);
|
||||
|
||||
expect(Buffer.isBuffer(result)).toBe(true);
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Airtop utils', () => {
|
||||
describe('validateRequiredStringField', () => {
|
||||
it('should validate non-empty string field', () => {
|
||||
const nodeParameters = {
|
||||
testField: 'test-value',
|
||||
};
|
||||
|
||||
const result = validateRequiredStringField.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
0,
|
||||
'testField',
|
||||
'Test Field',
|
||||
);
|
||||
expect(result).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should trim whitespace from string field', () => {
|
||||
const nodeParameters = {
|
||||
testField: ' test-value ',
|
||||
};
|
||||
|
||||
const result = validateRequiredStringField.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
0,
|
||||
'testField',
|
||||
'Test Field',
|
||||
);
|
||||
expect(result).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should throw error for empty string field', () => {
|
||||
const nodeParameters = {
|
||||
testField: '',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateRequiredStringField.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
0,
|
||||
'testField',
|
||||
'Test Field',
|
||||
),
|
||||
).toThrow(ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Test Field'));
|
||||
});
|
||||
|
||||
it('should throw error for whitespace-only string field', () => {
|
||||
const nodeParameters = {
|
||||
testField: ' ',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateRequiredStringField.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
0,
|
||||
'testField',
|
||||
'Test Field',
|
||||
),
|
||||
).toThrow(ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Test Field'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateProfileName', () => {
|
||||
it('should validate valid profile names', () => {
|
||||
const nodeParameters = {
|
||||
profileName: 'test-profile-123',
|
||||
};
|
||||
|
||||
const result = validateProfileName.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe('test-profile-123');
|
||||
});
|
||||
|
||||
it('should allow empty profile name', () => {
|
||||
const nodeParameters = {
|
||||
profileName: '',
|
||||
};
|
||||
|
||||
const result = validateProfileName.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should throw error for invalid profile name', () => {
|
||||
const nodeParameters = {
|
||||
profileName: 'test@profile#123',
|
||||
};
|
||||
|
||||
expect(() => validateProfileName.call(createMockExecuteFunction(nodeParameters), 0)).toThrow(
|
||||
ERROR_MESSAGES.PROFILE_NAME_INVALID,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTimeoutMinutes', () => {
|
||||
it('should validate valid timeout', () => {
|
||||
const nodeParameters = {
|
||||
timeoutMinutes: 10,
|
||||
};
|
||||
|
||||
const result = validateTimeoutMinutes.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
|
||||
it('should throw error for timeout below minimum', () => {
|
||||
const nodeParameters = {
|
||||
timeoutMinutes: 0,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateTimeoutMinutes.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).toThrow(ERROR_MESSAGES.TIMEOUT_MINUTES_INVALID);
|
||||
});
|
||||
|
||||
it('should throw error for timeout above maximum', () => {
|
||||
const nodeParameters = {
|
||||
timeoutMinutes: 10081,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateTimeoutMinutes.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).toThrow(ERROR_MESSAGES.TIMEOUT_MINUTES_INVALID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSaveProfileOnTermination', () => {
|
||||
it('should validate when save profile is false', () => {
|
||||
const nodeParameters = {
|
||||
saveProfileOnTermination: false,
|
||||
};
|
||||
|
||||
const result = validateSaveProfileOnTermination.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
0,
|
||||
'',
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate when save profile is true with profile name', () => {
|
||||
const nodeParameters = {
|
||||
saveProfileOnTermination: true,
|
||||
};
|
||||
|
||||
const result = validateSaveProfileOnTermination.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
0,
|
||||
'test-profile',
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when save profile is true without profile name', () => {
|
||||
const nodeParameters = {
|
||||
saveProfileOnTermination: true,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateSaveProfileOnTermination.call(createMockExecuteFunction(nodeParameters), 0, ''),
|
||||
).toThrow(ERROR_MESSAGES.PROFILE_NAME_REQUIRED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSessionId', () => {
|
||||
it('should validate session ID', () => {
|
||||
const nodeParameters = {
|
||||
sessionId: 'test-session-123',
|
||||
};
|
||||
|
||||
const result = validateSessionId.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe('test-session-123');
|
||||
});
|
||||
|
||||
it('should throw error for empty session ID', () => {
|
||||
const nodeParameters = {
|
||||
sessionId: '',
|
||||
};
|
||||
|
||||
expect(() => validateSessionId.call(createMockExecuteFunction(nodeParameters), 0)).toThrow(
|
||||
ERROR_MESSAGES.SESSION_ID_REQUIRED,
|
||||
);
|
||||
});
|
||||
|
||||
it('should trim whitespace from session ID', () => {
|
||||
const nodeParameters = {
|
||||
sessionId: ' test-session-123 ',
|
||||
};
|
||||
|
||||
const result = validateSessionId.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe('test-session-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSessionAndWindowId', () => {
|
||||
it('should validate session and window IDs', () => {
|
||||
const nodeParameters = {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
};
|
||||
|
||||
const result = validateSessionAndWindowId.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toEqual({
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for empty session ID', () => {
|
||||
const nodeParameters = {
|
||||
sessionId: '',
|
||||
windowId: 'win-123',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateSessionAndWindowId.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).toThrow(ERROR_MESSAGES.SESSION_ID_REQUIRED);
|
||||
});
|
||||
|
||||
it('should throw error for empty window ID', () => {
|
||||
const nodeParameters = {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: '',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateSessionAndWindowId.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).toThrow(ERROR_MESSAGES.WINDOW_ID_REQUIRED);
|
||||
});
|
||||
|
||||
it('should trim whitespace from IDs', () => {
|
||||
const nodeParameters = {
|
||||
sessionId: ' test-session-123 ',
|
||||
windowId: ' win-123 ',
|
||||
};
|
||||
|
||||
const result = validateSessionAndWindowId.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toEqual({
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUrl', () => {
|
||||
it('should validate valid URL', () => {
|
||||
const nodeParameters = {
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const result = validateUrl.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('should throw error for invalid URL', () => {
|
||||
const nodeParameters = {
|
||||
url: 'invalid-url',
|
||||
};
|
||||
|
||||
expect(() => validateUrl.call(createMockExecuteFunction(nodeParameters), 0)).toThrow(
|
||||
ERROR_MESSAGES.URL_INVALID,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty string for empty URL', () => {
|
||||
const nodeParameters = {
|
||||
url: '',
|
||||
};
|
||||
|
||||
const result = validateUrl.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should throw error for URL without http or https', () => {
|
||||
const nodeParameters = {
|
||||
url: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => validateUrl.call(createMockExecuteFunction(nodeParameters), 0)).toThrow(
|
||||
ERROR_MESSAGES.URL_INVALID,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAirtopApiResponse', () => {
|
||||
const mockNode = {
|
||||
id: '1',
|
||||
name: 'Airtop node',
|
||||
type: 'n8n-nodes-base.airtop',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
it('should not throw error for valid response', () => {
|
||||
const response = {
|
||||
status: 'success',
|
||||
data: {},
|
||||
meta: {},
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
expect(() => validateAirtopApiResponse(mockNode, response)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for response with errors', () => {
|
||||
const response = {
|
||||
status: 'error',
|
||||
data: {},
|
||||
meta: {},
|
||||
errors: [{ message: 'Error 1' }, { message: 'Error 2' }],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const expectedError = new NodeApiError(mockNode, { message: 'Error 1\nError 2' });
|
||||
expect(() => validateAirtopApiResponse(mockNode, response)).toThrow(expectedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldCreateNewSession', () => {
|
||||
it("should return true when 'sessionMode' is 'new'", () => {
|
||||
const nodeParameters = {
|
||||
sessionMode: 'new',
|
||||
};
|
||||
|
||||
const result = shouldCreateNewSession.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when 'sessionMode' is 'existing'", () => {
|
||||
const nodeParameters = {
|
||||
sessionMode: 'existing',
|
||||
};
|
||||
|
||||
const result = shouldCreateNewSession.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when 'sessionMode' is empty", () => {
|
||||
const nodeParameters = {
|
||||
sessionMode: '',
|
||||
};
|
||||
|
||||
const result = shouldCreateNewSession.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when 'sessionMode' is not set", () => {
|
||||
const nodeParameters = {};
|
||||
|
||||
const result = shouldCreateNewSession.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSessionAndWindow', () => {
|
||||
it("should create a new session and window when sessionMode is 'new'", async () => {
|
||||
const nodeParameters = {
|
||||
sessionMode: 'new',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const result = await createSessionAndWindow.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
0,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
sessionId: 'new-session-123',
|
||||
windowId: 'new-window-123',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
39
packages/nodes-base/nodes/Airtop/transport/index.ts
Normal file
39
packages/nodes-base/nodes/Airtop/transport/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
IHttpRequestMethods,
|
||||
IHttpRequestOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { IAirtopResponse } from './types';
|
||||
import { BASE_URL } from '../constants';
|
||||
|
||||
export async function apiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
query: IDataObject = {},
|
||||
) {
|
||||
const options: IHttpRequestOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method,
|
||||
body,
|
||||
qs: query,
|
||||
url: endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`,
|
||||
json: true,
|
||||
};
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
return (await this.helpers.httpRequestWithAuthentication.call(
|
||||
this,
|
||||
'airtopApi',
|
||||
options,
|
||||
)) as IAirtopResponse;
|
||||
}
|
||||
29
packages/nodes-base/nodes/Airtop/transport/types.ts
Normal file
29
packages/nodes-base/nodes/Airtop/transport/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
export interface IAirtopResponse extends IDataObject {
|
||||
sessionId?: string;
|
||||
data: IDataObject & {
|
||||
modelResponse?: string;
|
||||
};
|
||||
meta: IDataObject & {
|
||||
status?: string;
|
||||
screenshots?: Array<{ dataUrl: string }>;
|
||||
};
|
||||
errors: IDataObject[];
|
||||
warnings: IDataObject[];
|
||||
}
|
||||
|
||||
export interface IAirtopInteractionRequest extends IDataObject {
|
||||
text?: string;
|
||||
waitForNavigation?: boolean;
|
||||
elementDescription?: string;
|
||||
pressEnterKey?: boolean;
|
||||
configuration: {
|
||||
visualAnalysis?: {
|
||||
scope: string;
|
||||
};
|
||||
waitForNavigationConfig?: {
|
||||
waitUntil: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"dist/credentials/AirtableApi.credentials.js",
|
||||
"dist/credentials/AirtableOAuth2Api.credentials.js",
|
||||
"dist/credentials/AirtableTokenApi.credentials.js",
|
||||
"dist/credentials/AirtopApi.credentials.js",
|
||||
"dist/credentials/AlienVaultApi.credentials.js",
|
||||
"dist/credentials/Amqp.credentials.js",
|
||||
"dist/credentials/ApiTemplateIoApi.credentials.js",
|
||||
@@ -413,6 +414,7 @@
|
||||
"dist/nodes/AgileCrm/AgileCrm.node.js",
|
||||
"dist/nodes/Airtable/Airtable.node.js",
|
||||
"dist/nodes/Airtable/AirtableTrigger.node.js",
|
||||
"dist/nodes/Airtop/Airtop.node.js",
|
||||
"dist/nodes/AiTransform/AiTransform.node.js",
|
||||
"dist/nodes/Amqp/Amqp.node.js",
|
||||
"dist/nodes/Amqp/AmqpTrigger.node.js",
|
||||
|
||||
Reference in New Issue
Block a user