first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import * as agent from './actions/agent/Agent.resource';
|
||||
import * as extraction from './actions/extraction/Extraction.resource';
|
||||
import * as file from './actions/file/File.resource';
|
||||
import * as interaction from './actions/interaction/Interaction.resource';
|
||||
import { router } from './actions/router';
|
||||
import * as session from './actions/session/Session.resource';
|
||||
import * as window from './actions/window/Window.resource';
|
||||
import { agentsResourceMapping, listSearchAgents } from './methods';
|
||||
|
||||
export class Airtop implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Airtop',
|
||||
name: 'airtop',
|
||||
icon: 'file:airtop.svg',
|
||||
group: ['transform'],
|
||||
defaultVersion: 1,
|
||||
version: [1, 1.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: 'Agent',
|
||||
value: 'agent',
|
||||
},
|
||||
{
|
||||
name: 'Extraction',
|
||||
value: 'extraction',
|
||||
},
|
||||
{
|
||||
name: 'File',
|
||||
value: 'file',
|
||||
},
|
||||
{
|
||||
name: 'Interaction',
|
||||
value: 'interaction',
|
||||
},
|
||||
{
|
||||
name: 'Session',
|
||||
value: 'session',
|
||||
},
|
||||
{
|
||||
name: 'Window',
|
||||
value: 'window',
|
||||
},
|
||||
],
|
||||
default: 'session',
|
||||
},
|
||||
...agent.description,
|
||||
...session.description,
|
||||
...window.description,
|
||||
...file.description,
|
||||
...extraction.description,
|
||||
...interaction.description,
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
listSearch: {
|
||||
listSearchAgents,
|
||||
},
|
||||
resourceMapping: {
|
||||
agentsResourceMapping,
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import {
|
||||
NodeApiError,
|
||||
type IExecuteFunctions,
|
||||
type INode,
|
||||
type IDataObject,
|
||||
jsonParse,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type Stream from 'node:stream';
|
||||
|
||||
import { SESSION_MODE } from './actions/common/fields';
|
||||
import { BASE_URL, type TScrollingMode } from './constants';
|
||||
import {
|
||||
ERROR_MESSAGES,
|
||||
DEFAULT_TIMEOUT_MINUTES,
|
||||
DEFAULT_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
MIN_TIMEOUT_MINUTES,
|
||||
MAX_TIMEOUT_MINUTES,
|
||||
SESSION_STATUS,
|
||||
OPERATION_TIMEOUT,
|
||||
} from './constants';
|
||||
import { apiRequest } from './transport';
|
||||
import type {
|
||||
IAirtopResponse,
|
||||
IAirtopServerEvent,
|
||||
IAirtopSessionResponse,
|
||||
} 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 configuration
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @returns The validated proxy configuration
|
||||
*/
|
||||
export function validateProxy(this: IExecuteFunctions, index: number) {
|
||||
const proxyParam = this.getNodeParameter('proxy', index, '') as
|
||||
| 'none'
|
||||
| 'integrated'
|
||||
| 'proxyUrl';
|
||||
const proxyConfig = this.getNodeParameter('proxyConfig', index, '') as {
|
||||
country: string;
|
||||
sticky: boolean;
|
||||
};
|
||||
const isConfigEmpty = Object.keys(proxyConfig).length === 0;
|
||||
|
||||
if (proxyParam === 'integrated') {
|
||||
return {
|
||||
proxy: isConfigEmpty ? true : { ...proxyConfig },
|
||||
};
|
||||
}
|
||||
|
||||
// handle custom proxy configuration
|
||||
if (proxyParam === 'proxyUrl') {
|
||||
return {
|
||||
proxy: validateRequiredStringField.call(this, index, 'proxyUrl', 'Proxy URL'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
proxy: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the scrollBy amount parameter
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @param parameterName - The name of the parameter
|
||||
* @returns The validated scrollBy amount
|
||||
*/
|
||||
export function validateScrollByAmount(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
parameterName: string,
|
||||
) {
|
||||
const regex = /^(?:-?\d{1,3}(?:%|px))$/;
|
||||
const scrollBy = this.getNodeParameter(parameterName, index, {}) as {
|
||||
xAxis?: string;
|
||||
yAxis?: string;
|
||||
};
|
||||
|
||||
if (!scrollBy?.xAxis && !scrollBy?.yAxis) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const allAxisValid = [scrollBy.xAxis, scrollBy.yAxis]
|
||||
.filter(Boolean)
|
||||
.every((axis) => regex.test(axis ?? ''));
|
||||
|
||||
if (!allAxisValid) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SCROLL_BY_AMOUNT_INVALID, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return scrollBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the scroll mode parameter
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @returns Scroll mode
|
||||
* @throws Error if the scroll mode or scroll parameters are invalid
|
||||
*/
|
||||
export function validateScrollingMode(this: IExecuteFunctions, index: number): TScrollingMode {
|
||||
const scrollingMode = this.getNodeParameter(
|
||||
'scrollingMode',
|
||||
index,
|
||||
'automatic',
|
||||
) as TScrollingMode;
|
||||
|
||||
const scrollToEdge = this.getNodeParameter('scrollToEdge.edgeValues', index, {}) as {
|
||||
xAxis?: string;
|
||||
yAxis?: string;
|
||||
};
|
||||
const scrollBy = this.getNodeParameter('scrollBy.scrollValues', index, {}) as {
|
||||
xAxis?: string;
|
||||
yAxis?: string;
|
||||
};
|
||||
|
||||
if (scrollingMode !== 'manual') {
|
||||
return scrollingMode;
|
||||
}
|
||||
|
||||
// validate manual scroll parameters
|
||||
const emptyScrollBy = !scrollBy.xAxis && !scrollBy.yAxis;
|
||||
const emptyScrollToEdge = !scrollToEdge.xAxis && !scrollToEdge.yAxis;
|
||||
|
||||
if (emptyScrollBy && emptyScrollToEdge) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SCROLL_MODE_INVALID, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
return scrollingMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 wait until the session is ready
|
||||
* @param this - The execution context
|
||||
* @param parameters - The parameters for the session
|
||||
* @returns The session ID
|
||||
*/
|
||||
export async function createSession(
|
||||
this: IExecuteFunctions,
|
||||
parameters: IDataObject,
|
||||
timeout = OPERATION_TIMEOUT,
|
||||
): Promise<{ sessionId: string; data: IAirtopSessionResponse }> {
|
||||
// Request session creation
|
||||
const response = (await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
'/sessions',
|
||||
parameters,
|
||||
)) as IAirtopSessionResponse;
|
||||
const sessionId = response?.data?.id;
|
||||
|
||||
if (!sessionId) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: 'Failed to create session',
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
|
||||
// Poll until the session is ready or timeout is reached
|
||||
let sessionStatus = response?.data?.status;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (sessionStatus !== SESSION_STATUS.RUNNING) {
|
||||
if (Date.now() - startTime > timeout) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: ERROR_MESSAGES.TIMEOUT_REACHED,
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const sessionStatusResponse = (await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/sessions/${sessionId}`,
|
||||
)) as IAirtopSessionResponse;
|
||||
sessionStatus = sessionStatusResponse.data.status;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
data: {
|
||||
...response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 profileName = validateProfileName.call(this, index);
|
||||
const url = validateRequiredStringField.call(this, index, 'url', 'URL');
|
||||
|
||||
const { sessionId } = await createSession.call(this, {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE Helpers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parses a server event from a string
|
||||
* @param eventText - The string to parse
|
||||
* @returns The parsed event or null if the string is not a valid event
|
||||
*/
|
||||
function parseEvent(eventText: string): IAirtopServerEvent | null {
|
||||
const dataLine = eventText.split('\n').find((line) => line.startsWith('data:'));
|
||||
if (!dataLine) {
|
||||
return null;
|
||||
}
|
||||
const jsonStr = dataLine.replace('data: ', '').trim();
|
||||
return jsonParse<IAirtopServerEvent>(jsonStr, {
|
||||
errorMessage: 'Failed to parse server event',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a session event to occur
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param sessionId - ID of the session to check for events
|
||||
* @param condition - Function to check if the event meets the condition
|
||||
* @param timeoutInSeconds - Maximum time in seconds to wait before failing (defaults to DEFAULT_DOWNLOAD_TIMEOUT_SECONDS)
|
||||
* @returns Promise resolving to the event when the condition is met
|
||||
*/
|
||||
export async function waitForSessionEvent(
|
||||
this: IExecuteFunctions,
|
||||
sessionId: string,
|
||||
condition: (event: IAirtopServerEvent) => boolean,
|
||||
timeoutInSeconds = DEFAULT_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
): Promise<IAirtopServerEvent> {
|
||||
const url = `${BASE_URL}/sessions/${sessionId}/events?all=true`;
|
||||
let stream: Stream;
|
||||
|
||||
const eventPromise = new Promise<IAirtopServerEvent>(async (resolve) => {
|
||||
stream = (await this.helpers.httpRequestWithAuthentication.call(this, 'airtopApi', {
|
||||
method: 'GET',
|
||||
url,
|
||||
encoding: 'stream',
|
||||
})) as Stream;
|
||||
|
||||
stream.on('data', (data: Uint8Array) => {
|
||||
const event = parseEvent(data.toString());
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
// handle event
|
||||
if (condition(event)) {
|
||||
stream.removeAllListeners();
|
||||
resolve(event);
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(
|
||||
new NodeApiError(this.getNode(), {
|
||||
message: ERROR_MESSAGES.TIMEOUT_REACHED,
|
||||
code: 500,
|
||||
}),
|
||||
);
|
||||
stream.removeAllListeners();
|
||||
}, timeoutInSeconds * 1000);
|
||||
});
|
||||
|
||||
const result = await Promise.race([eventPromise, timeoutPromise]);
|
||||
return result as IAirtopServerEvent;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"invocationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modelResponse": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"windowId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 2
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modelResponse": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": 3
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modelResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scrapedContent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contentType": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"windowId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 2
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modelResponse": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"windowId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 2
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modelResponse": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"windowId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cdpUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"cdpWsUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"chromedriverUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"configuration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseProfileId": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeoutMinutes": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dateCreated": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastActivity": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"type": "null"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": 5
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"liveViewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"targetId": {
|
||||
"type": "string"
|
||||
},
|
||||
"windowId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"type": "null"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"warnings": {
|
||||
"type": "null"
|
||||
},
|
||||
"windowId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 4
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as run from './run.operation';
|
||||
|
||||
export { run };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Run',
|
||||
value: 'run',
|
||||
description: 'Run an Airtop agent',
|
||||
action: 'Run an agent',
|
||||
},
|
||||
],
|
||||
default: 'run',
|
||||
},
|
||||
...run.description,
|
||||
];
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IDataObject, ResourceMapperField } from 'n8n-workflow';
|
||||
|
||||
export interface AgentSchemaProperty {
|
||||
description?: string;
|
||||
type: string;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
export interface AirtopAgentSchema {
|
||||
$schema?: string;
|
||||
additionalProperties?: boolean;
|
||||
properties?: Record<string, AgentSchemaProperty>;
|
||||
required?: string[];
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
}
|
||||
|
||||
export interface AirtopAgentResponse extends IDataObject {
|
||||
agent: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
versionData: {
|
||||
configVarsSchema?: AirtopAgentSchema;
|
||||
resultSchema?: AirtopAgentSchema;
|
||||
};
|
||||
webhookId: string;
|
||||
}
|
||||
|
||||
export interface AgentsListResponse extends IDataObject {
|
||||
agents: Array<AirtopAgentResponse['agent']>;
|
||||
}
|
||||
|
||||
export interface AgentParametersInput {
|
||||
value?: IDataObject;
|
||||
schema: ResourceMapperField[];
|
||||
}
|
||||
|
||||
export interface AgentInvocationResponse extends IDataObject {
|
||||
invocationId: string;
|
||||
}
|
||||
|
||||
export interface AgentResultResponse extends IDataObject {
|
||||
status: 'Completed' | 'Running' | 'Failed' | 'Unknown';
|
||||
output?: IDataObject;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { IDataObject, IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { AgentParametersInput, AgentResultResponse, AirtopAgentResponse } from './agent.types';
|
||||
import { AIRTOP_HOOKS_BASE_URL, BASE_URL_V2, ERROR_MESSAGES } from '../../constants';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
/**
|
||||
* Gets the agent input parameters schema.
|
||||
*/
|
||||
export async function getAgentDetails(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
agentId: string,
|
||||
): Promise<AirtopAgentResponse> {
|
||||
return await apiRequest.call<
|
||||
IExecuteFunctions | ILoadOptionsFunctions,
|
||||
['GET', string],
|
||||
Promise<AirtopAgentResponse>
|
||||
>(this, 'GET', `${BASE_URL_V2}/agents/${agentId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the agent parameters with the schema.
|
||||
*/
|
||||
export function validateAgentParameters(
|
||||
this: IExecuteFunctions,
|
||||
params: AgentParametersInput,
|
||||
): IDataObject {
|
||||
const inputParameters = params?.value ?? {};
|
||||
const requiredParameters = (params?.schema ?? [])
|
||||
.filter((field) => field.required)
|
||||
.map((field) => field.id);
|
||||
// check for empty values on required fields
|
||||
const missingRequiredParameters = requiredParameters.filter((reqParam) => {
|
||||
return (
|
||||
inputParameters[reqParam] === undefined ||
|
||||
inputParameters[reqParam] === null ||
|
||||
inputParameters[reqParam] === ''
|
||||
);
|
||||
});
|
||||
if (missingRequiredParameters.length) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Missing required parameters: ${missingRequiredParameters.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { configVars: inputParameters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the agent status
|
||||
*/
|
||||
export async function getAgentStatus(
|
||||
this: IExecuteFunctions,
|
||||
agentId: string,
|
||||
invocationId: string,
|
||||
): Promise<AgentResultResponse> {
|
||||
const resultUrl = `${AIRTOP_HOOKS_BASE_URL}/agents/${agentId}/invocations/${invocationId}/result`;
|
||||
return await apiRequest.call<IExecuteFunctions, ['GET', string], Promise<AgentResultResponse>>(
|
||||
this,
|
||||
'GET',
|
||||
resultUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the agent execution status until it's completed or fails.
|
||||
*/
|
||||
export async function pollAgentStatus(
|
||||
this: IExecuteFunctions,
|
||||
agentId: string,
|
||||
invocationId: string,
|
||||
timeoutSeconds: number,
|
||||
): Promise<AgentResultResponse | undefined> {
|
||||
const airtopNode = this.getNode();
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let response: AgentResultResponse | undefined;
|
||||
|
||||
this.logger.info(`[${airtopNode.name}] Polling agent status for invocationId: ${invocationId}`);
|
||||
|
||||
while (true) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
throwOperationErrorIf(elapsed >= timeoutMs, ERROR_MESSAGES.TIMEOUT_REACHED, airtopNode);
|
||||
|
||||
response = await getAgentStatus.call(this, agentId, invocationId);
|
||||
|
||||
if (response?.output || response?.error) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Wait one second before next poll
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an operation error if the statement is true.
|
||||
*/
|
||||
export function throwOperationErrorIf(statement: boolean, message: string, node: INode) {
|
||||
if (statement) {
|
||||
throw new NodeOperationError(node, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { AgentInvocationResponse, AgentParametersInput } from './agent.types';
|
||||
import {
|
||||
getAgentDetails,
|
||||
pollAgentStatus,
|
||||
throwOperationErrorIf,
|
||||
validateAgentParameters,
|
||||
} from './agent.utils';
|
||||
import { AGENT_MIN_TIMEOUT_SECONDS, AIRTOP_HOOKS_BASE_URL, ERROR_MESSAGES } from '../../constants';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Agent',
|
||||
name: 'agentId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
description:
|
||||
'The Airtop agent to run. Visit <a href="https://portal.airtop.ai/agents" target="_blank">Airtop Agents</a> for more information.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select an Agent...',
|
||||
typeOptions: {
|
||||
searchListMethod: 'listSearchAgents',
|
||||
searchFilterRequired: false,
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. agent_abc123',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '.+',
|
||||
errorMessage: 'Agent ID cannot be empty',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Agent Parameters',
|
||||
name: 'agentParameters',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['agentId.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'agentsResourceMapping',
|
||||
mode: 'map',
|
||||
supportAutoMap: false,
|
||||
addAllFields: true,
|
||||
noFieldsError: 'No input parameters found for the selected agent',
|
||||
multiKeyMatch: false,
|
||||
allowEmptyValues: true,
|
||||
fieldWords: {
|
||||
singular: 'parameter',
|
||||
plural: 'parameters',
|
||||
},
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
},
|
||||
hide: {
|
||||
agentId: [''],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Await Agent',
|
||||
name: 'awaitExecution',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to wait for the agent to complete its execution',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
name: 'timeout',
|
||||
type: 'number',
|
||||
default: 600,
|
||||
description: 'Timeout in seconds to wait for the agent to finish',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['agent'],
|
||||
operation: ['run'],
|
||||
awaitExecution: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const airtopNode = this.getNode();
|
||||
const agentId = this.getNodeParameter('agentId', index, '', {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const agentParameters = this.getNodeParameter(
|
||||
'agentParameters',
|
||||
index,
|
||||
{},
|
||||
) as AgentParametersInput;
|
||||
|
||||
const awaitExecution = this.getNodeParameter('awaitExecution', index, true) as boolean;
|
||||
|
||||
const timeout = this.getNodeParameter('timeout', index, 600) as number;
|
||||
|
||||
// Validate timeout
|
||||
throwOperationErrorIf(
|
||||
timeout < AGENT_MIN_TIMEOUT_SECONDS,
|
||||
ERROR_MESSAGES.AGENT_TIMEOUT_INVALID,
|
||||
airtopNode,
|
||||
);
|
||||
|
||||
// Convert fixedCollection parameters to API format
|
||||
const validatedAgentParameters = validateAgentParameters.call(this, agentParameters);
|
||||
|
||||
const { webhookId } = await getAgentDetails.call(this, agentId);
|
||||
const invokeUrl = `${AIRTOP_HOOKS_BASE_URL}/agents/${agentId}/webhooks/${webhookId}`;
|
||||
|
||||
const invocationResponse = await apiRequest.call<
|
||||
IExecuteFunctions,
|
||||
['POST', string, IDataObject],
|
||||
Promise<AgentInvocationResponse>
|
||||
>(this, 'POST', invokeUrl, validatedAgentParameters);
|
||||
|
||||
const invocationId = invocationResponse.invocationId;
|
||||
throwOperationErrorIf(
|
||||
!invocationId,
|
||||
"No 'invocationId' received from agent webhook response",
|
||||
airtopNode,
|
||||
);
|
||||
|
||||
if (!awaitExecution) {
|
||||
return this.helpers.returnJsonArray({
|
||||
invocationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Poll for agent's execution status
|
||||
const result = await pollAgentStatus.call(this, agentId, invocationId, timeout);
|
||||
throwOperationErrorIf(
|
||||
Boolean(result?.error),
|
||||
`${result?.error ?? 'Unknown error'}. Agent Invocation ID: ${invocationId}`,
|
||||
airtopNode,
|
||||
);
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
invocationId,
|
||||
status: result?.status ?? 'Unknown',
|
||||
output: result?.output ?? {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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: '',
|
||||
};
|
||||
|
||||
export const parseJsonOutputField: INodeProperties = {
|
||||
displayName: 'Parse JSON Output',
|
||||
name: 'parseJsonOutput',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
"Whether to parse the model's response to JSON in the output. Requires the 'JSON Output Schema' parameter to be set.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Interaction related fields
|
||||
*/
|
||||
|
||||
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],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const includeHiddenElementsField: INodeProperties = {
|
||||
displayName: 'Include Hidden Elements',
|
||||
name: 'includeHiddenElements',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to include hidden elements in the interaction',
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, IDataObject } from 'n8n-workflow';
|
||||
|
||||
import type { IAirtopNodeExecutionData, IAirtopResponse } from '../../transport/types';
|
||||
|
||||
/**
|
||||
* Parse JSON when the 'Parse JSON Output' parameter is enabled
|
||||
* @param this - The execution context
|
||||
* @param index - The index of the node
|
||||
* @param response - The Airtop API response to parse
|
||||
* @returns The parsed output
|
||||
*/
|
||||
export function parseJsonIfPresent(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
response: IAirtopResponse,
|
||||
): IAirtopResponse {
|
||||
const parseJsonOutput = this.getNodeParameter('additionalFields.parseJsonOutput', index, false);
|
||||
const outputJsonSchema = this.getNodeParameter(
|
||||
'additionalFields.outputSchema',
|
||||
index,
|
||||
'',
|
||||
) as string;
|
||||
|
||||
if (!parseJsonOutput || !outputJsonSchema.startsWith('{')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const output = JSON.parse(response.data?.modelResponse ?? '') as IDataObject;
|
||||
return {
|
||||
sessionId: response.sessionId,
|
||||
windowId: response.windowId,
|
||||
output,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), 'Output is not a valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the output when used as a tool
|
||||
* @param output - The output to clean up
|
||||
* @returns The cleaned up output
|
||||
*/
|
||||
export function cleanOutputForToolUse(output: IAirtopNodeExecutionData[]) {
|
||||
const getOutput = (executionData: IAirtopNodeExecutionData) => {
|
||||
// Return error message
|
||||
if (executionData.json?.errors?.length) {
|
||||
const errorMessage = executionData.json?.errors[0].message as string;
|
||||
return {
|
||||
output: `Error: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Return output parsed from JSON
|
||||
if (executionData.json?.output) {
|
||||
return executionData.json?.output;
|
||||
}
|
||||
|
||||
// Return model response
|
||||
if (executionData.json?.data?.modelResponse) {
|
||||
return {
|
||||
output: executionData.json?.data?.modelResponse,
|
||||
};
|
||||
}
|
||||
|
||||
// Return everything else
|
||||
return {
|
||||
output: { ...(executionData.json?.data ?? {}) },
|
||||
};
|
||||
};
|
||||
|
||||
return output.map((executionData) => ({
|
||||
...executionData,
|
||||
json: {
|
||||
...getOutput(executionData),
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IExecuteFunctions, IDataObject } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateSessionAndWindowId,
|
||||
createSessionAndWindow,
|
||||
shouldCreateNewSession,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type { IAirtopResponse } from '../../transport/types';
|
||||
|
||||
/**
|
||||
* Execute the node operation. Creates and terminates a new session if needed.
|
||||
* @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<IAirtopResponse> {
|
||||
let airtopSessionId = '';
|
||||
try {
|
||||
const { sessionId, windowId } = shouldCreateNewSession.call(this, index)
|
||||
? await createSessionAndWindow.call(this, index)
|
||||
: validateSessionAndWindowId.call(this, index);
|
||||
airtopSessionId = sessionId;
|
||||
|
||||
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 response;
|
||||
}
|
||||
|
||||
return { sessionId, windowId, ...response };
|
||||
} catch (error) {
|
||||
// terminate session on error
|
||||
if (airtopSessionId) {
|
||||
await apiRequest.call(this, 'DELETE', `/sessions/${airtopSessionId}`);
|
||||
this.logger.info(`[${this.getNode().name}] Session terminated.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -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,125 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { outputSchemaField, parseJsonOutputField } from '../common/fields';
|
||||
import { parseJsonIfPresent } from '../common/output.utils';
|
||||
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,
|
||||
},
|
||||
{
|
||||
...parseJsonOutputField,
|
||||
},
|
||||
{
|
||||
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);
|
||||
|
||||
const configFields = ['paginationMode', 'interactionMode', 'outputSchema'];
|
||||
const configuration = configFields.reduce(
|
||||
(config, key) => (additionalFields[key] ? { ...config, [key]: additionalFields[key] } : config),
|
||||
{},
|
||||
);
|
||||
|
||||
const result = await executeRequestWithSessionManagement.call(this, index, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/paginated-extraction',
|
||||
body: {
|
||||
prompt,
|
||||
configuration,
|
||||
},
|
||||
});
|
||||
|
||||
const nodeOutput = parseJsonIfPresent.call(this, index, result);
|
||||
return this.helpers.returnJsonArray(nodeOutput);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { outputSchemaField, parseJsonOutputField } from '../common/fields';
|
||||
import { parseJsonIfPresent } from '../common/output.utils';
|
||||
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,
|
||||
},
|
||||
{
|
||||
...parseJsonOutputField,
|
||||
},
|
||||
{
|
||||
displayName: 'Include Visual Analysis',
|
||||
name: 'includeVisualAnalysis',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to analyze the web page visually when fulfilling the request',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const prompt = this.getNodeParameter('prompt', index, '') as string;
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index, {});
|
||||
const outputSchema = additionalFields.outputSchema;
|
||||
const includeVisualAnalysis = additionalFields.includeVisualAnalysis;
|
||||
|
||||
const result = await executeRequestWithSessionManagement.call(this, index, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/page-query',
|
||||
body: {
|
||||
prompt,
|
||||
configuration: {
|
||||
experimental: {
|
||||
includeVisualAnalysis: includeVisualAnalysis ? 'enabled' : 'disabled',
|
||||
},
|
||||
...(outputSchema ? { outputSchema } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const nodeOutput = parseJsonIfPresent.call(this, index, result);
|
||||
return this.helpers.returnJsonArray(nodeOutput);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { executeRequestWithSessionManagement } from '../common/session.utils';
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const result = await executeRequestWithSessionManagement.call(this, index, {
|
||||
method: 'POST',
|
||||
path: '/sessions/{sessionId}/windows/{windowId}/scrape-content',
|
||||
body: {},
|
||||
});
|
||||
|
||||
return this.helpers.returnJsonArray({ ...result });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as deleteFile from './delete.operation';
|
||||
import * as get from './get.operation';
|
||||
import * as getMany from './getMany.operation';
|
||||
import * as load from './load.operation';
|
||||
import * as upload from './upload.operation';
|
||||
|
||||
export { deleteFile, get, getMany, upload, load };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'deleteFile',
|
||||
description: 'Delete an uploaded file',
|
||||
action: 'Delete a file',
|
||||
},
|
||||
{
|
||||
name: 'Get',
|
||||
value: 'get',
|
||||
description: 'Get a details of an uploaded file',
|
||||
action: 'Get a file',
|
||||
},
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'getMany',
|
||||
description: 'Get details of multiple uploaded files',
|
||||
action: 'Get many files',
|
||||
},
|
||||
{
|
||||
name: 'Load',
|
||||
value: 'load',
|
||||
description: 'Load a file into a session',
|
||||
action: 'Load a file',
|
||||
},
|
||||
{
|
||||
name: 'Upload',
|
||||
value: 'upload',
|
||||
description: 'Upload a file into a session',
|
||||
action: 'Upload a file',
|
||||
},
|
||||
],
|
||||
default: 'getMany',
|
||||
},
|
||||
...deleteFile.description,
|
||||
...get.description,
|
||||
...getMany.description,
|
||||
...load.description,
|
||||
...upload.description,
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES } from '../../constants';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'ID of the file to delete',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['deleteFile'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const fileId = this.getNodeParameter('fileId', index, '') as string;
|
||||
|
||||
if (!fileId) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'File ID'),
|
||||
);
|
||||
}
|
||||
|
||||
await apiRequest.call(this, 'DELETE', `/files/${fileId}`);
|
||||
|
||||
return this.helpers.returnJsonArray({ data: { message: 'File deleted successfully' } });
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES } from '../../constants';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type { IAirtopResponseWithFiles } from '../../transport/types';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['get'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'ID of the file to retrieve',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Output Binary File',
|
||||
name: 'outputBinaryFile',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to output the file in binary format if the file is ready for download',
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const fileId = this.getNodeParameter('fileId', index, '') as string;
|
||||
const outputBinaryFile = this.getNodeParameter('outputBinaryFile', index, false);
|
||||
|
||||
if (!fileId) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'File ID'),
|
||||
);
|
||||
}
|
||||
|
||||
const response = (await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/files/${fileId}`,
|
||||
)) as IAirtopResponseWithFiles;
|
||||
|
||||
const { fileName = '', downloadUrl = '', status = '' } = response?.data ?? {};
|
||||
|
||||
// Handle binary file output
|
||||
if (outputBinaryFile && downloadUrl && status === 'available') {
|
||||
const buffer = (await this.helpers.httpRequest({
|
||||
url: downloadUrl,
|
||||
json: false,
|
||||
encoding: 'arraybuffer',
|
||||
})) as Buffer;
|
||||
const file = await this.helpers.prepareBinaryData(buffer, fileName);
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
...response,
|
||||
},
|
||||
binary: { data: file },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({ ...response });
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { requestAllFiles } from './helpers';
|
||||
import { wrapData } from '../../../../utils/utilities';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type { IAirtopResponse } from '../../transport/types';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['getMany'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['getMany'],
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 100,
|
||||
},
|
||||
default: 10,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
{
|
||||
displayName: 'Session IDs',
|
||||
name: 'sessionIds',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Comma-separated list of <a href="https://docs.airtop.ai/api-reference/airtop-api/sessions/create" target="_blank">Session IDs</a> to filter files by. When empty, all files from all sessions will be returned.',
|
||||
placeholder: 'e.g. 6aac6f73-bd89-4a76-ab32-5a6c422e8b0b, a13c6f73-bd89-4a76-ab32-5a6c422e8224',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Output Files in Single Item',
|
||||
name: 'outputSingleItem',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to output one item containing all files or output each file as a separate item',
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnAll = this.getNodeParameter('returnAll', index, false);
|
||||
const limit = this.getNodeParameter('limit', index, 10);
|
||||
const sessionIds = this.getNodeParameter('sessionIds', index, '') as string;
|
||||
const outputSingleItem = this.getNodeParameter('outputSingleItem', index, true) as boolean;
|
||||
|
||||
const endpoint = '/files';
|
||||
let files: IAirtopResponse[] = [];
|
||||
|
||||
const responseData = returnAll
|
||||
? await requestAllFiles.call(this, sessionIds)
|
||||
: await apiRequest.call(this, 'GET', endpoint, {}, { sessionIds, limit });
|
||||
|
||||
if (responseData.data?.files && Array.isArray(responseData.data?.files)) {
|
||||
files = responseData.data.files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the files in one of two formats:
|
||||
* - A single JSON item containing an array of all files (when outputSingleItem = true)
|
||||
* - Multiple JSON items, one per file
|
||||
* Data structure reference: https://docs.n8n.io/courses/level-two/chapter-1/#data-structure-of-n8n
|
||||
*/
|
||||
if (outputSingleItem) {
|
||||
return this.helpers.returnJsonArray({ ...responseData });
|
||||
}
|
||||
return wrapData(files);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import pick from 'lodash/pick';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants';
|
||||
import { waitForSessionEvent } from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type {
|
||||
IAirtopFileInputRequest,
|
||||
IAirtopResponseWithFiles,
|
||||
IAirtopServerEvent,
|
||||
} from '../../transport/types';
|
||||
|
||||
/**
|
||||
* Fetches all files from the Airtop API using pagination
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param sessionIds - Comma-separated string of session IDs to filter files by
|
||||
* @returns Promise resolving to a response object containing the complete array of files
|
||||
*/
|
||||
export async function requestAllFiles(
|
||||
this: IExecuteFunctions,
|
||||
sessionIds: string,
|
||||
): Promise<IAirtopResponseWithFiles> {
|
||||
const endpoint = '/files';
|
||||
let hasMore = true;
|
||||
let currentOffset = 0;
|
||||
const limit = 100;
|
||||
const files: IAirtopResponseWithFiles['data']['files'] = [];
|
||||
let responseData: IAirtopResponseWithFiles;
|
||||
|
||||
while (hasMore) {
|
||||
// request files
|
||||
responseData = (await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
endpoint,
|
||||
{},
|
||||
{ offset: currentOffset, limit, sessionIds },
|
||||
)) as IAirtopResponseWithFiles;
|
||||
// add files to the array
|
||||
if (responseData.data?.files && Array.isArray(responseData.data?.files)) {
|
||||
files.push.apply(files, responseData.data.files);
|
||||
}
|
||||
// check if there are more files
|
||||
hasMore = Boolean(responseData.data?.pagination?.hasMore);
|
||||
currentOffset += limit;
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
files,
|
||||
pagination: {
|
||||
hasMore,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the Airtop API until a file reaches "available" status or times out
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param fileId - The unique identifier of the file to poll
|
||||
* @param timeout - Maximum time in milliseconds to wait before failing (defaults to OPERATION_TIMEOUT)
|
||||
* @param intervalSeconds - Time in seconds to wait between polling attempts (defaults to 1)
|
||||
* @returns Promise resolving to the file ID when the file is available
|
||||
* @throws NodeApiError if the operation times out or API request fails
|
||||
*/
|
||||
export async function pollFileUntilAvailable(
|
||||
this: IExecuteFunctions,
|
||||
fileId: string,
|
||||
timeout = OPERATION_TIMEOUT,
|
||||
intervalSeconds = 1,
|
||||
): Promise<string> {
|
||||
let fileStatus = '';
|
||||
const startTime = Date.now();
|
||||
while (fileStatus !== 'available') {
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
if (elapsedTime >= timeout) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: ERROR_MESSAGES.TIMEOUT_REACHED,
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', `/files/${fileId}`);
|
||||
fileStatus = response.data?.status as string;
|
||||
// Wait before the next polling attempt
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
|
||||
}
|
||||
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file entry in Airtop, uploads the file content, and waits until processing completes
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param fileName - Name to assign to the uploaded file
|
||||
* @param fileBuffer - Buffer containing the binary file data to upload
|
||||
* @param fileType - Classification of the file in Airtop (e.g., 'customer_upload')
|
||||
* @param pollingFunction - Function to use for checking file availability (defaults to pollFileUntilAvailable)
|
||||
* @returns Promise resolving to the file ID once upload is complete and file is available
|
||||
* @throws NodeApiError if file creation, upload, or polling fails
|
||||
*/
|
||||
export async function createAndUploadFile(
|
||||
this: IExecuteFunctions,
|
||||
fileName: string,
|
||||
fileBuffer: Buffer,
|
||||
fileType: string,
|
||||
pollingFunction = pollFileUntilAvailable,
|
||||
): Promise<string> {
|
||||
// Create file entry
|
||||
const createResponse = await apiRequest.call(this, 'POST', '/files', {
|
||||
fileName,
|
||||
fileType,
|
||||
});
|
||||
|
||||
const fileId = createResponse.data?.id;
|
||||
const uploadUrl = createResponse.data?.uploadUrl as string;
|
||||
|
||||
if (!fileId || !uploadUrl) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: 'Failed to create file entry: missing file ID or upload URL',
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
|
||||
// Upload the file
|
||||
await this.helpers.httpRequest({
|
||||
method: 'PUT',
|
||||
url: uploadUrl,
|
||||
body: fileBuffer,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
|
||||
// Poll until the file is available
|
||||
return await pollingFunction.call(this, fileId as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a file to be ready in a session by polling file's information
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param sessionId - ID of the session to check for file availability
|
||||
* @param fileId - ID of the file
|
||||
* @param timeout - Maximum time in milliseconds to wait before failing (defaults to OPERATION_TIMEOUT)
|
||||
* @returns Promise that resolves when a file in the session becomes available
|
||||
* @throws NodeApiError if the timeout is reached before a file becomes available
|
||||
*/
|
||||
export async function waitForFileInSession(
|
||||
this: IExecuteFunctions,
|
||||
sessionId: string,
|
||||
fileId: string,
|
||||
timeout = OPERATION_TIMEOUT,
|
||||
): Promise<void> {
|
||||
// Wait for a "file_upload_status" event with status "available" or "upload_failed"
|
||||
const condition = (sessionEvent: IAirtopServerEvent) => {
|
||||
const { event, status } = sessionEvent;
|
||||
return (
|
||||
sessionEvent.fileId === fileId &&
|
||||
event === 'file_upload_status' &&
|
||||
(status === 'available' || status === 'upload_failed')
|
||||
);
|
||||
};
|
||||
|
||||
const event = await waitForSessionEvent.call(this, sessionId, condition, timeout);
|
||||
if (event.status === 'upload_failed') {
|
||||
const error = new NodeApiError(this.getNode(), {
|
||||
message: event.eventData?.error ?? `Upload failed for File ID: ${fileId}`,
|
||||
code: 500,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a file with a session and waits until the file is ready for use
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param fileId - ID of the file to associate with the session
|
||||
* @param sessionId - ID of the session to add the file to
|
||||
* @param pollingFunction - Function to use for checking file availability in session (defaults to waitForFileInSession)
|
||||
* @returns Promise that resolves when the file is ready for use in the session
|
||||
*/
|
||||
export async function pushFileToSession(
|
||||
this: IExecuteFunctions,
|
||||
fileId: string,
|
||||
sessionId: string,
|
||||
pollingFunction = waitForFileInSession,
|
||||
): Promise<void> {
|
||||
// Push file into session
|
||||
await apiRequest.call(this, 'POST', `/files/${fileId}/push`, { sessionIds: [sessionId] });
|
||||
await pollingFunction.call(this, sessionId, fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates a file upload input in a specific window within a session
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param fileId - ID of the file to use for the input
|
||||
* @param windowId - ID of the window where the file input will be triggered
|
||||
* @param sessionId - ID of the session containing the window
|
||||
* @returns Promise that resolves when the file input has been triggered
|
||||
*/
|
||||
export async function triggerFileInput(
|
||||
this: IExecuteFunctions,
|
||||
request: IAirtopFileInputRequest,
|
||||
): Promise<void> {
|
||||
await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${request.sessionId}/windows/${request.windowId}/file-input`,
|
||||
pick(request, ['fileId', 'elementDescription', 'includeHiddenElements']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file Buffer from either a URL or binary data
|
||||
* This function supports two source types:
|
||||
* - URL: Downloads the file from the specified URL and returns it as a Buffer
|
||||
* - Binary: Retrieves binary data from the workflow's binary data storage
|
||||
*
|
||||
* @param this - The execution context providing access to n8n functionality
|
||||
* @param source - Source type, either 'url' or 'binary'
|
||||
* @param value - Either a URL string or binary data property name depending on source type
|
||||
* @param itemIndex - Index of the workflow item to get binary data from (when source is 'binary')
|
||||
* @returns Promise resolving to a Buffer containing the file data
|
||||
* @throws NodeApiError if the source type is unsupported or retrieval fails
|
||||
*/
|
||||
export async function createFileBuffer(
|
||||
this: IExecuteFunctions,
|
||||
source: string,
|
||||
value: string,
|
||||
itemIndex: number,
|
||||
): Promise<Buffer> {
|
||||
if (source === 'url') {
|
||||
const buffer = (await this.helpers.httpRequest({
|
||||
url: value,
|
||||
json: false,
|
||||
encoding: 'arraybuffer',
|
||||
})) as Buffer;
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
if (source === 'binary') {
|
||||
const binaryData = await this.helpers.getBinaryDataBuffer(itemIndex, value);
|
||||
return binaryData;
|
||||
}
|
||||
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: `Unsupported source type: ${source}. Please use 'url' or 'binary'`,
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { pushFileToSession, triggerFileInput } from './helpers';
|
||||
import {
|
||||
sessionIdField,
|
||||
windowIdField,
|
||||
elementDescriptionField,
|
||||
includeHiddenElementsField,
|
||||
} from '../common/fields';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['load'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...sessionIdField,
|
||||
description: 'The session ID to load the file into',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
description: 'The window ID to trigger the file input in',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'File ID',
|
||||
name: 'fileId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'ID of the file to load into the session',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...elementDescriptionField,
|
||||
description: 'Optional description of the file input to interact with',
|
||||
placeholder: 'e.g. the file upload selection box',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...includeHiddenElementsField,
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const fileId = this.getNodeParameter('fileId', index, '') as string;
|
||||
const sessionId = this.getNodeParameter('sessionId', index, '') as string;
|
||||
const windowId = this.getNodeParameter('windowId', index, '') as string;
|
||||
const elementDescription = this.getNodeParameter('elementDescription', index, '') as string;
|
||||
const includeHiddenElements = this.getNodeParameter(
|
||||
'includeHiddenElements',
|
||||
index,
|
||||
false,
|
||||
) as boolean;
|
||||
|
||||
try {
|
||||
await pushFileToSession.call(this, fileId, sessionId);
|
||||
await triggerFileInput.call(this, {
|
||||
fileId,
|
||||
windowId,
|
||||
sessionId,
|
||||
elementDescription,
|
||||
includeHiddenElements,
|
||||
});
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
sessionId,
|
||||
windowId,
|
||||
data: {
|
||||
message: 'File loaded successfully',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), error as Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
createAndUploadFile,
|
||||
pushFileToSession,
|
||||
triggerFileInput,
|
||||
createFileBuffer,
|
||||
} from './helpers';
|
||||
import { validateRequiredStringField } from '../../GenericFunctions';
|
||||
import {
|
||||
sessionIdField,
|
||||
windowIdField,
|
||||
elementDescriptionField,
|
||||
includeHiddenElementsField,
|
||||
} from '../common/fields';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['file'],
|
||||
operation: ['upload'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...sessionIdField,
|
||||
description: 'The session ID to load the file into',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
description: 'The window ID to trigger the file input in',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'File Name',
|
||||
name: 'fileName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
description:
|
||||
'Name for the file to upload. For a session, all files loaded should have <b>unique names</b>.',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'File Type',
|
||||
name: 'fileType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Browser Download',
|
||||
value: 'browser_download',
|
||||
},
|
||||
{
|
||||
name: 'Screenshot',
|
||||
value: 'screenshot',
|
||||
},
|
||||
{
|
||||
name: 'Video',
|
||||
value: 'video',
|
||||
},
|
||||
{
|
||||
name: 'Customer Upload',
|
||||
value: 'customer_upload',
|
||||
},
|
||||
],
|
||||
default: 'customer_upload',
|
||||
description: "Choose the type of file to upload. Defaults to 'Customer Upload'.",
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Source',
|
||||
name: 'source',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'URL',
|
||||
value: 'url',
|
||||
},
|
||||
{
|
||||
name: 'Binary',
|
||||
value: 'binary',
|
||||
},
|
||||
],
|
||||
default: 'url',
|
||||
description: 'Source of the file to upload',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Binary Property',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['binary'],
|
||||
...displayOptions.show,
|
||||
},
|
||||
},
|
||||
description: 'Name of the binary property containing the file data',
|
||||
},
|
||||
{
|
||||
displayName: 'URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['url'],
|
||||
...displayOptions.show,
|
||||
},
|
||||
},
|
||||
description: 'URL from where to fetch the file to upload',
|
||||
},
|
||||
{
|
||||
displayName: 'Trigger File Input',
|
||||
name: 'triggerFileInputParameter',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to automatically trigger the file input dialog in the current window. If disabled, the file will only be uploaded to the session without opening the file input dialog.',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...elementDescriptionField,
|
||||
description: 'Optional description of the file input to interact with',
|
||||
placeholder: 'e.g. the file upload selection box',
|
||||
displayOptions: {
|
||||
show: {
|
||||
triggerFileInputParameter: [true],
|
||||
...displayOptions.show,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...includeHiddenElementsField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
triggerFileInputParameter: [true],
|
||||
...displayOptions.show,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateRequiredStringField.call(this, index, 'sessionId', 'Session ID');
|
||||
const windowId = validateRequiredStringField.call(this, index, 'windowId', 'Window ID');
|
||||
const fileName = this.getNodeParameter('fileName', index, '') as string;
|
||||
const fileType = this.getNodeParameter('fileType', index, 'customer_upload') as string;
|
||||
const source = this.getNodeParameter('source', index, 'url') as string;
|
||||
const url = this.getNodeParameter('url', index, '') as string;
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', index, '');
|
||||
const triggerFileInputParameter = this.getNodeParameter(
|
||||
'triggerFileInputParameter',
|
||||
index,
|
||||
true,
|
||||
) as boolean;
|
||||
const elementDescription = this.getNodeParameter('elementDescription', index, '') as string;
|
||||
const includeHiddenElements = this.getNodeParameter(
|
||||
'includeHiddenElements',
|
||||
index,
|
||||
false,
|
||||
) as boolean;
|
||||
// Get the file content based on source type
|
||||
const fileValue = source === 'url' ? url : binaryPropertyName;
|
||||
|
||||
try {
|
||||
const fileBuffer = await createFileBuffer.call(this, source, fileValue, index);
|
||||
const fileId = await createAndUploadFile.call(this, fileName, fileBuffer, fileType);
|
||||
// Push file to session
|
||||
await pushFileToSession.call(this, fileId, sessionId);
|
||||
|
||||
if (triggerFileInputParameter) {
|
||||
await triggerFileInput.call(this, {
|
||||
fileId,
|
||||
windowId,
|
||||
sessionId,
|
||||
elementDescription,
|
||||
includeHiddenElements,
|
||||
});
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
sessionId,
|
||||
windowId,
|
||||
data: {
|
||||
fileId,
|
||||
message: 'File uploaded successfully',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), error as Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as click from './click.operation';
|
||||
import * as fill from './fill.operation';
|
||||
import * as hover from './hover.operation';
|
||||
import * as scroll from './scroll.operation';
|
||||
import * as type from './type.operation';
|
||||
import { sessionIdField, windowIdField } from '../common/fields';
|
||||
export { click, fill, hover, scroll, 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: 'Fill Form',
|
||||
value: 'fill',
|
||||
description: 'Fill a form with the provided information',
|
||||
action: 'Fill form',
|
||||
},
|
||||
{
|
||||
name: 'Hover on an Element',
|
||||
value: 'hover',
|
||||
description: 'Execute a hover action on an element given a description',
|
||||
action: 'Hover on an element',
|
||||
},
|
||||
{
|
||||
name: 'Scroll',
|
||||
value: 'scroll',
|
||||
description: 'Execute a scroll action on the page',
|
||||
action: 'Scroll on page',
|
||||
},
|
||||
{
|
||||
name: 'Type',
|
||||
value: 'type',
|
||||
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,
|
||||
...fill.description,
|
||||
...hover.description,
|
||||
...scroll.description,
|
||||
...type.description,
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['click', 'hover', 'type', 'scroll'],
|
||||
},
|
||||
},
|
||||
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,88 @@
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Click Type',
|
||||
name: 'clickType',
|
||||
type: 'options',
|
||||
default: 'click',
|
||||
description: 'The type of click to perform. Defaults to left click.',
|
||||
options: [
|
||||
{
|
||||
name: 'Left Click',
|
||||
value: 'click',
|
||||
},
|
||||
{
|
||||
name: 'Double Click',
|
||||
value: 'doubleClick',
|
||||
},
|
||||
{
|
||||
name: 'Right Click',
|
||||
value: 'rightClick',
|
||||
},
|
||||
],
|
||||
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 clickType = validateRequiredStringField.call(this, index, 'clickType', 'Click Type');
|
||||
|
||||
const request = constructInteractionRequest.call(this, index, {
|
||||
elementDescription,
|
||||
configuration: {
|
||||
clickType,
|
||||
},
|
||||
});
|
||||
|
||||
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,88 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
NodeApiError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants';
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import type { IAirtopResponse } from '../../transport/types';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Form Data',
|
||||
name: 'formData',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 4,
|
||||
},
|
||||
required: true,
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['fill'],
|
||||
},
|
||||
},
|
||||
description: 'The information to fill into the form written in natural language',
|
||||
placeholder: 'e.g. "Name: John Doe, Email: john.doe@example.com"',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
timeout = OPERATION_TIMEOUT,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const formData = validateRequiredStringField.call(this, index, 'formData', 'Form Data');
|
||||
|
||||
// run automation
|
||||
const asyncAutomationResponse = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/async/sessions/${sessionId}/windows/${windowId}/execute-automation`,
|
||||
{
|
||||
automationId: 'auto',
|
||||
parameters: {
|
||||
customData: formData,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const reqId = asyncAutomationResponse.requestId as string;
|
||||
|
||||
// Poll status every second until it's completed or timeout is reached
|
||||
const startTime = Date.now();
|
||||
let automationStatusResponse: IAirtopResponse;
|
||||
|
||||
while (true) {
|
||||
automationStatusResponse = await apiRequest.call(this, 'GET', `/requests/${reqId}/status`);
|
||||
const status = automationStatusResponse?.status ?? '';
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), automationStatusResponse);
|
||||
|
||||
if (status === 'completed' || status === 'error') {
|
||||
break;
|
||||
}
|
||||
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
if (elapsedTime >= timeout) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: ERROR_MESSAGES.TIMEOUT_REACHED,
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
|
||||
// Wait one second
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...automationStatusResponse });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 = {
|
||||
...parameters,
|
||||
configuration: {
|
||||
...(parameters.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,
|
||||
};
|
||||
}
|
||||
|
||||
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,224 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { constructInteractionRequest } from './helpers';
|
||||
import {
|
||||
validateRequiredStringField,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
validateScrollByAmount,
|
||||
validateScrollingMode,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Scroll Mode',
|
||||
name: 'scrollingMode',
|
||||
type: 'options',
|
||||
description: 'Choose the mode of scrolling',
|
||||
options: [
|
||||
{
|
||||
name: 'Automatic',
|
||||
value: 'automatic',
|
||||
description: 'Describe with natural language the element to scroll to',
|
||||
},
|
||||
{
|
||||
name: 'Manual',
|
||||
value: 'manual',
|
||||
description: 'Define the direction and amount to scroll by',
|
||||
},
|
||||
],
|
||||
default: 'automatic',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Element Description',
|
||||
default: '',
|
||||
description: 'A natural language description of the element to scroll to',
|
||||
name: 'scrollToElement',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. the page section titled "Contact Us"',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
scrollingMode: ['automatic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Scroll To Edge',
|
||||
name: 'scrollToEdge',
|
||||
type: 'fixedCollection',
|
||||
default: {},
|
||||
placeholder: 'Add Edge Direction',
|
||||
description:
|
||||
"The direction to scroll to. When 'Scroll By' is defined, 'Scroll To Edge' action will be executed first, then 'Scroll By' action.",
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
scrollingMode: ['manual'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Page Edges',
|
||||
name: 'edgeValues',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Vertically',
|
||||
name: 'yAxis',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [
|
||||
{
|
||||
name: 'Empty',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
name: 'Top',
|
||||
value: 'top',
|
||||
},
|
||||
{
|
||||
name: 'Bottom',
|
||||
value: 'bottom',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Horizontally',
|
||||
name: 'xAxis',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [
|
||||
{
|
||||
name: 'Empty',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
name: 'Left',
|
||||
value: 'left',
|
||||
},
|
||||
{
|
||||
name: 'Right',
|
||||
value: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Scroll By',
|
||||
name: 'scrollBy',
|
||||
type: 'fixedCollection',
|
||||
default: {},
|
||||
description:
|
||||
"The amount to scroll by. When 'Scroll To Edge' is defined, 'Scroll By' action will be executed after 'Scroll To Edge'.",
|
||||
placeholder: 'Add Scroll Amount',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
scrollingMode: ['manual'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'scrollValues',
|
||||
displayName: 'Scroll Values',
|
||||
description: 'The amount in pixels or percentage to scroll by',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Vertically',
|
||||
name: 'yAxis',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. 200px, 50%, -100px',
|
||||
},
|
||||
{
|
||||
displayName: 'Horizontally',
|
||||
name: 'xAxis',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. 50px, 10%, -200px',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Scrollable Area',
|
||||
name: 'scrollWithin',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Scroll within an element on the page',
|
||||
placeholder: 'e.g. the left sidebar',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
|
||||
const scrollingMode = validateScrollingMode.call(this, index);
|
||||
const isAutomatic = scrollingMode === 'automatic';
|
||||
|
||||
const scrollToElement = isAutomatic
|
||||
? validateRequiredStringField.call(this, index, 'scrollToElement', 'Element Description')
|
||||
: '';
|
||||
|
||||
const scrollToEdge = this.getNodeParameter('scrollToEdge.edgeValues', index, {}) as {
|
||||
xAxis?: string;
|
||||
yAxis?: string;
|
||||
};
|
||||
|
||||
const scrollBy = validateScrollByAmount.call(this, index, 'scrollBy.scrollValues');
|
||||
|
||||
const scrollWithin = this.getNodeParameter('scrollWithin', index, '') as string;
|
||||
|
||||
const request: IDataObject = {
|
||||
// when scrollingMode is 'Manual'
|
||||
...(!isAutomatic ? { scrollToEdge } : {}),
|
||||
...(!isAutomatic ? { scrollBy } : {}),
|
||||
// when scrollingMode is 'Automatic'
|
||||
...(isAutomatic ? { scrollToElement } : {}),
|
||||
// when scrollWithin is defined
|
||||
...(scrollWithin ? { scrollWithin } : {}),
|
||||
};
|
||||
|
||||
const fullRequest = constructInteractionRequest.call(this, index, request);
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/scroll`,
|
||||
fullRequest,
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
type NodeMap = {
|
||||
agent: 'run';
|
||||
session: 'create' | 'save' | 'terminate';
|
||||
window: 'create' | 'close' | 'takeScreenshot' | 'load';
|
||||
extraction: 'getPaginated' | 'query' | 'scrape';
|
||||
interaction: 'click' | 'fill' | 'hover' | 'type';
|
||||
file: 'getMany' | 'get' | 'deleteFile' | 'upload' | 'load';
|
||||
};
|
||||
|
||||
export type AirtopType = AllEntities<NodeMap>;
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as agent from './agent/Agent.resource';
|
||||
import { cleanOutputForToolUse } from './common/output.utils';
|
||||
import * as extraction from './extraction/Extraction.resource';
|
||||
import * as file from './file/File.resource';
|
||||
import * as interaction from './interaction/Interaction.resource';
|
||||
import type { AirtopType } from './node.type';
|
||||
import * as session from './session/Session.resource';
|
||||
import * as window from './window/Window.resource';
|
||||
import type { IAirtopNodeExecutionData } from '../transport/types';
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const operationResult: INodeExecutionData[] = [];
|
||||
let responseData: IAirtopNodeExecutionData[] = [];
|
||||
const nodeType = this.getNode().type;
|
||||
const isCalledAsTool = nodeType.includes('airtopTool');
|
||||
|
||||
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 'agent':
|
||||
responseData = await agent[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
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;
|
||||
case 'file':
|
||||
responseData = await file[airtopNodeData.operation].execute.call(this, i);
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The resource "${resource}" is not supported!`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get cleaner output when called as tool
|
||||
if (isCalledAsTool) {
|
||||
responseData = cleanOutputForToolUse(responseData);
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(responseData, {
|
||||
itemData: { item: i },
|
||||
});
|
||||
|
||||
operationResult.push.apply(operationResult, 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,54 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as save from './save.operation';
|
||||
import * as terminate from './terminate.operation';
|
||||
import * as waitForDownload from './waitForDownload.operation';
|
||||
|
||||
export { create, save, terminate, waitForDownload };
|
||||
|
||||
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',
|
||||
},
|
||||
{
|
||||
name: 'Wait for Download',
|
||||
value: 'waitForDownload',
|
||||
description: 'Wait for a file download to become available',
|
||||
action: 'Wait for a download',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
...create.description,
|
||||
...save.description,
|
||||
...terminate.description,
|
||||
...waitForDownload.description,
|
||||
];
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { COUNTRIES } from '../../countries';
|
||||
import {
|
||||
createSession,
|
||||
validateProfileName,
|
||||
validateProxy,
|
||||
validateSaveProfileOnTermination,
|
||||
validateTimeoutMinutes,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { profileNameField } from '../common/fields';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['create'],
|
||||
},
|
||||
};
|
||||
|
||||
const countryOptions = COUNTRIES.map(({ name, value }) => ({ name, value }));
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...profileNameField,
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
/* Session Recording */
|
||||
{
|
||||
displayName: 'Record Session',
|
||||
name: 'record',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to record the browser session. <a href="https://docs.airtop.ai/guides/how-to/recording-a-session" target="_blank">More details</a>.',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Idle Timeout',
|
||||
name: 'timeoutMinutes',
|
||||
type: 'number',
|
||||
default: 10,
|
||||
validateType: 'number',
|
||||
description: 'Minutes to wait before the session is terminated due to inactivity',
|
||||
displayOptions,
|
||||
},
|
||||
/**
|
||||
* Proxy Configuration
|
||||
*/
|
||||
{
|
||||
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: 'Proxy URL',
|
||||
value: 'proxyUrl',
|
||||
description: 'Use a proxy URL to configure the proxy',
|
||||
},
|
||||
],
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Proxy Configuration',
|
||||
name: 'proxyConfig',
|
||||
type: 'collection',
|
||||
default: { country: 'US', sticky: true },
|
||||
description: 'The Airtop-provided configuration to use for the proxy',
|
||||
placeholder: 'Add Attribute',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Country',
|
||||
name: 'country',
|
||||
type: 'options',
|
||||
default: 'US',
|
||||
description:
|
||||
'The country to use for the proxy. Not all countries are guaranteed to provide a proxy. Learn more <a href="https://docs.airtop.ai/api-reference/airtop-api/sessions/create#request.body.configuration.proxy.Proxy.Airtop-Proxy-Configuration.country" target="_blank">here</a>.',
|
||||
options: countryOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Keep Same IP',
|
||||
name: 'sticky',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether to try to maintain the same IP address for the duration of the session. Airtop can guarantee that the same IP address will be available for up to a maximum of 30 minutes.',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
...displayOptions.show,
|
||||
proxy: ['integrated'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Proxy URL',
|
||||
name: 'proxyUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The URL of the proxy to use',
|
||||
validateType: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
...displayOptions.show,
|
||||
proxy: ['proxyUrl'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions,
|
||||
options: [
|
||||
{
|
||||
displayName: 'Auto Solve Captchas',
|
||||
name: 'solveCaptcha',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to automatically solve <a href="https://docs.airtop.ai/guides/how-to/solving-captchas" target="_blank">captcha challenges</a>',
|
||||
},
|
||||
{
|
||||
displayName: 'Extension IDs',
|
||||
name: 'extensionIds',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. extId1, extId2, ...',
|
||||
description:
|
||||
'Comma-separated extension IDs from the Google Web Store to be loaded into the session. Learn more <a href="https://docs.airtop.ai/guides/how-to/using-chrome-extensions" target="_blank">here</a>.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const profileName = validateProfileName.call(this, index);
|
||||
const record = this.getNodeParameter('record', index, false);
|
||||
const timeoutMinutes = validateTimeoutMinutes.call(this, index);
|
||||
const saveProfileOnTermination = validateSaveProfileOnTermination.call(this, index, profileName);
|
||||
const { proxy } = validateProxy.call(this, index);
|
||||
const solveCaptcha = this.getNodeParameter(
|
||||
'additionalFields.solveCaptcha',
|
||||
index,
|
||||
false,
|
||||
) as boolean;
|
||||
|
||||
const extensions = this.getNodeParameter('additionalFields.extensionIds', index, '') as string;
|
||||
const extensionIds = extensions ? extensions.split(',').map((id) => id.trim()) : [];
|
||||
|
||||
const body: IDataObject = {
|
||||
configuration: {
|
||||
profileName,
|
||||
timeoutMinutes,
|
||||
proxy,
|
||||
solveCaptcha,
|
||||
record,
|
||||
...(extensionIds.length > 0 ? { extensionIds } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const { sessionId, data } = await createSession.call(this, body);
|
||||
|
||||
if (saveProfileOnTermination) {
|
||||
await apiRequest.call(
|
||||
this,
|
||||
'PUT',
|
||||
`/sessions/${sessionId}/save-profile-on-termination/${profileName}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, ...data });
|
||||
}
|
||||
@@ -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 IDataObject,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { DEFAULT_DOWNLOAD_TIMEOUT_SECONDS } from '../../constants';
|
||||
import { validateSessionId, waitForSessionEvent } from '../../GenericFunctions';
|
||||
import { sessionIdField } from '../common/fields';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['session'],
|
||||
operation: ['waitForDownload'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions,
|
||||
options: [
|
||||
{
|
||||
displayName: 'Timeout',
|
||||
description: 'Time in seconds to wait for the download to become available',
|
||||
name: 'timeout',
|
||||
type: 'number',
|
||||
default: DEFAULT_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateSessionId.call(this, index);
|
||||
const timeout = this.getNodeParameter(
|
||||
'timeout',
|
||||
index,
|
||||
DEFAULT_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
) as number;
|
||||
|
||||
// Wait for a file_status event with status 'available'
|
||||
const event = await waitForSessionEvent.call(
|
||||
this,
|
||||
sessionId,
|
||||
(sessionEvent) => sessionEvent.event === 'file_status' && sessionEvent.status === 'available',
|
||||
timeout,
|
||||
);
|
||||
|
||||
// Extract fileId and downloadUrl from the event
|
||||
const result: IDataObject = {
|
||||
fileId: event.fileId,
|
||||
downloadUrl: event.downloadUrl,
|
||||
};
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
sessionId,
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as close from './close.operation';
|
||||
import * as create from './create.operation';
|
||||
import * as getLiveView from './getLiveView.operation';
|
||||
import * as list from './list.operation';
|
||||
import * as load from './load.operation';
|
||||
import * as takeScreenshot from './takeScreenshot.operation';
|
||||
import { sessionIdField, windowIdField } from '../common/fields';
|
||||
|
||||
export { create, close, takeScreenshot, load, list, getLiveView };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
sortable: false,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Close Window',
|
||||
value: 'close',
|
||||
description: 'Close a window inside a session',
|
||||
action: 'Close a window',
|
||||
},
|
||||
{
|
||||
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: 'Get Live View',
|
||||
value: 'getLiveView',
|
||||
description: 'Get information about a browser window, including the live view URL',
|
||||
action: 'Get live view',
|
||||
},
|
||||
{
|
||||
name: 'List Windows',
|
||||
value: 'list',
|
||||
description: 'List all browser windows in a session',
|
||||
action: 'List windows',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
{
|
||||
...sessionIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...windowIdField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['close', 'takeScreenshot', 'load', 'getLiveView'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...create.description,
|
||||
...list.description,
|
||||
...getLiveView.description,
|
||||
...load.description,
|
||||
...takeScreenshot.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 its 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,93 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES } from '../../constants';
|
||||
import { validateAirtopApiResponse, validateSessionAndWindowId } from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['getLiveView'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
displayName: 'Disable Resize',
|
||||
name: 'disableResize',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to disable the window from being resized in the Live View',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const additionalFields = this.getNodeParameter('additionalFields', index);
|
||||
|
||||
const queryParams: Record<string, any> = {};
|
||||
|
||||
if (additionalFields.includeNavigationBar) {
|
||||
queryParams.includeNavigationBar = true;
|
||||
}
|
||||
|
||||
if (additionalFields.screenResolution) {
|
||||
const screenResolution = ((additionalFields.screenResolution as string) || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const regex = /^\d{3,4}x\d{3,4}$/; // Expected format: 1280x720
|
||||
|
||||
if (!regex.test(screenResolution)) {
|
||||
throw new NodeOperationError(this.getNode(), ERROR_MESSAGES.SCREEN_RESOLUTION_INVALID, {
|
||||
itemIndex: index,
|
||||
});
|
||||
}
|
||||
|
||||
queryParams.screenResolution = screenResolution;
|
||||
}
|
||||
|
||||
if (additionalFields.disableResize) {
|
||||
queryParams.disableResize = true;
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/sessions/${sessionId}/windows/${windowId}`,
|
||||
undefined,
|
||||
queryParams,
|
||||
);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, windowId, ...response });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { validateAirtopApiResponse, validateSessionId } from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const sessionId = validateSessionId.call(this, index);
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', `/sessions/${sessionId}/windows`, undefined);
|
||||
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
return this.helpers.returnJsonArray({ sessionId, ...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,70 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
IBinaryData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
convertScreenshotToBinary,
|
||||
} from '../../GenericFunctions';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Output Binary Image',
|
||||
description: 'Whether to output the image as a binary file instead of a base64 encoded string',
|
||||
name: 'outputImageAsBinary',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['takeScreenshot'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
index: number,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const { sessionId, windowId } = validateSessionAndWindowId.call(this, index);
|
||||
const outputImageAsBinary = this.getNodeParameter('outputImageAsBinary', index, false) as boolean;
|
||||
|
||||
let data: IBinaryData | undefined; // for storing the binary data
|
||||
let image = ''; // for storing the base64 encoded image
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/sessions/${sessionId}/windows/${windowId}/screenshot`,
|
||||
);
|
||||
|
||||
// validate response
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
// process screenshot on success
|
||||
if (response.meta?.screenshots?.length) {
|
||||
if (outputImageAsBinary) {
|
||||
const buffer = convertScreenshotToBinary(response.meta.screenshots[0]);
|
||||
data = await this.helpers.prepareBinaryData(buffer, 'screenshot.jpg', 'image/jpeg');
|
||||
} else {
|
||||
image = response?.meta?.screenshots?.[0].dataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
sessionId,
|
||||
windowId,
|
||||
image,
|
||||
},
|
||||
...(data ? { binary: { data } } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,66 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import type { n8n } from 'n8n-core';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
// Helper function to get n8n version that can be mocked in tests
|
||||
export const getN8NVersion = (): string => {
|
||||
if (process.env.N8N_VERSION) {
|
||||
return process.env.N8N_VERSION;
|
||||
}
|
||||
|
||||
try {
|
||||
const PACKAGE_DIR = resolve(__dirname, '../../../');
|
||||
const packageJsonPath = join(PACKAGE_DIR, 'package.json');
|
||||
const n8nPackageJson = jsonParse<n8n.PackageJson>(readFileSync(packageJsonPath, 'utf8'));
|
||||
return n8nPackageJson.version;
|
||||
} catch (error) {
|
||||
// Fallback version
|
||||
return '0.0.0';
|
||||
}
|
||||
};
|
||||
|
||||
export const N8N_VERSION = getN8NVersion();
|
||||
|
||||
export const BASE_URL = process.env.AIRTOP_BASE_URL ?? 'https://api.airtop.ai/api/v1';
|
||||
export const BASE_URL_V2 = process.env.AIRTOP_BASE_URL_V2 ?? 'https://api.airtop.ai/api/v2';
|
||||
export const AIRTOP_HOOKS_BASE_URL =
|
||||
process.env.AIRTOP_HOOKS_BASE_URL ?? 'https://api.airtop.ai/api/hooks';
|
||||
|
||||
// Session operations
|
||||
export const DEFAULT_TIMEOUT_MINUTES = 10;
|
||||
export const MIN_TIMEOUT_MINUTES = 1;
|
||||
export const MAX_TIMEOUT_MINUTES = 10080;
|
||||
export const DEFAULT_DOWNLOAD_TIMEOUT_SECONDS = 30;
|
||||
export const SESSION_STATUS = {
|
||||
INITIALIZING: 'initializing',
|
||||
RUNNING: 'running',
|
||||
} as const;
|
||||
|
||||
// Operations
|
||||
export const OPERATION_TIMEOUT = 5 * 60 * 1000; // 5 mins
|
||||
export const AGENT_MIN_TIMEOUT_SECONDS = 10;
|
||||
|
||||
// Scroll operation
|
||||
export type TScrollingMode = 'manual' | 'automatic';
|
||||
|
||||
// Error messages
|
||||
export const ERROR_MESSAGES = {
|
||||
SESSION_ID_REQUIRED: "Please fill the 'Session ID' parameter",
|
||||
WINDOW_ID_REQUIRED: "Please fill the 'Window ID' parameter",
|
||||
URL_REQUIRED: "Please fill the 'URL' parameter",
|
||||
PROFILE_NAME_INVALID: "'Profile Name' should only contain letters, numbers and dashes",
|
||||
TIMEOUT_MINUTES_INVALID: `Timeout must be between ${MIN_TIMEOUT_MINUTES} and ${MAX_TIMEOUT_MINUTES} minutes`,
|
||||
TIMEOUT_REACHED: 'Timeout reached while waiting for the operation to complete',
|
||||
AGENT_TIMEOUT_INVALID: `Timeout must be at least ${AGENT_MIN_TIMEOUT_SECONDS} seconds`,
|
||||
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')",
|
||||
SCROLL_BY_AMOUNT_INVALID:
|
||||
"'Scroll By' amount must be a number and either a percentage or pixels (e.g. '100px' or '100%')",
|
||||
SCROLL_MODE_INVALID: "Please fill any of the 'Scroll To Edge' or 'Scroll By' parameters",
|
||||
} as const;
|
||||
@@ -0,0 +1,998 @@
|
||||
export const COUNTRIES = [
|
||||
{
|
||||
value: 'AF',
|
||||
name: 'Afghanistan',
|
||||
},
|
||||
{
|
||||
value: 'AX',
|
||||
name: 'Aland Islands',
|
||||
},
|
||||
{
|
||||
value: 'AL',
|
||||
name: 'Albania',
|
||||
},
|
||||
{
|
||||
value: 'DZ',
|
||||
name: 'Algeria',
|
||||
},
|
||||
{
|
||||
value: 'AS',
|
||||
name: 'American Samoa',
|
||||
},
|
||||
{
|
||||
value: 'AD',
|
||||
name: 'Andorra',
|
||||
},
|
||||
{
|
||||
value: 'AO',
|
||||
name: 'Angola',
|
||||
},
|
||||
{
|
||||
value: 'AI',
|
||||
name: 'Anguilla',
|
||||
},
|
||||
{
|
||||
value: 'AQ',
|
||||
name: 'Antarctica',
|
||||
},
|
||||
{
|
||||
value: 'AG',
|
||||
name: 'Antigua and Barbuda',
|
||||
},
|
||||
{
|
||||
value: 'AR',
|
||||
name: 'Argentina',
|
||||
},
|
||||
{
|
||||
value: 'AM',
|
||||
name: 'Armenia',
|
||||
},
|
||||
{
|
||||
value: 'AW',
|
||||
name: 'Aruba',
|
||||
},
|
||||
{
|
||||
value: 'AU',
|
||||
name: 'Australia',
|
||||
},
|
||||
{
|
||||
value: 'AT',
|
||||
name: 'Austria',
|
||||
},
|
||||
{
|
||||
value: 'AZ',
|
||||
name: 'Azerbaijan',
|
||||
},
|
||||
{
|
||||
value: 'BS',
|
||||
name: 'Bahamas',
|
||||
},
|
||||
{
|
||||
value: 'BH',
|
||||
name: 'Bahrain',
|
||||
},
|
||||
{
|
||||
value: 'BD',
|
||||
name: 'Bangladesh',
|
||||
},
|
||||
{
|
||||
value: 'BB',
|
||||
name: 'Barbados',
|
||||
},
|
||||
{
|
||||
value: 'BY',
|
||||
name: 'Belarus',
|
||||
},
|
||||
{
|
||||
value: 'BE',
|
||||
name: 'Belgium',
|
||||
},
|
||||
{
|
||||
value: 'BZ',
|
||||
name: 'Belize',
|
||||
},
|
||||
{
|
||||
value: 'BJ',
|
||||
name: 'Benin',
|
||||
},
|
||||
{
|
||||
value: 'BM',
|
||||
name: 'Bermuda',
|
||||
},
|
||||
{
|
||||
value: 'BT',
|
||||
name: 'Bhutan',
|
||||
},
|
||||
{
|
||||
value: 'BO',
|
||||
name: 'Bolivia, Plurinational State Of',
|
||||
},
|
||||
{
|
||||
value: 'BQ',
|
||||
name: 'Bonaire, Sint Eustatius and Saba',
|
||||
},
|
||||
{
|
||||
value: 'BA',
|
||||
name: 'Bosnia and Herzegovina',
|
||||
},
|
||||
{
|
||||
value: 'BW',
|
||||
name: 'Botswana',
|
||||
},
|
||||
{
|
||||
value: 'BV',
|
||||
name: 'Bouvet Island',
|
||||
},
|
||||
{
|
||||
value: 'BR',
|
||||
name: 'Brazil',
|
||||
},
|
||||
{
|
||||
value: 'IO',
|
||||
name: 'British Indian Ocean Territory',
|
||||
},
|
||||
{
|
||||
value: 'BN',
|
||||
name: 'Brunei Darussalam',
|
||||
},
|
||||
{
|
||||
value: 'BG',
|
||||
name: 'Bulgaria',
|
||||
},
|
||||
{
|
||||
value: 'BF',
|
||||
name: 'Burkina Faso',
|
||||
},
|
||||
{
|
||||
value: 'BI',
|
||||
name: 'Burundi',
|
||||
},
|
||||
{
|
||||
value: 'CV',
|
||||
name: 'Cabo Verde',
|
||||
},
|
||||
{
|
||||
value: 'KH',
|
||||
name: 'Cambodia',
|
||||
},
|
||||
{
|
||||
value: 'CM',
|
||||
name: 'Cameroon',
|
||||
},
|
||||
{
|
||||
value: 'CA',
|
||||
name: 'Canada',
|
||||
},
|
||||
{
|
||||
value: 'KY',
|
||||
name: 'Cayman Islands',
|
||||
},
|
||||
{
|
||||
value: 'CF',
|
||||
name: 'Central African Republic',
|
||||
},
|
||||
{
|
||||
value: 'TD',
|
||||
name: 'Chad',
|
||||
},
|
||||
{
|
||||
value: 'CL',
|
||||
name: 'Chile',
|
||||
},
|
||||
{
|
||||
value: 'CN',
|
||||
name: 'China',
|
||||
},
|
||||
{
|
||||
value: 'CX',
|
||||
name: 'Christmas Island',
|
||||
},
|
||||
{
|
||||
value: 'CC',
|
||||
name: 'Cocos (Keeling) Islands',
|
||||
},
|
||||
{
|
||||
value: 'CO',
|
||||
name: 'Colombia',
|
||||
},
|
||||
{
|
||||
value: 'KM',
|
||||
name: 'Comoros',
|
||||
},
|
||||
{
|
||||
value: 'CG',
|
||||
name: 'Congo',
|
||||
},
|
||||
{
|
||||
value: 'CD',
|
||||
name: 'Congo, Democratic Republic of The',
|
||||
},
|
||||
{
|
||||
value: 'CK',
|
||||
name: 'Cook Islands',
|
||||
},
|
||||
{
|
||||
value: 'CR',
|
||||
name: 'Costa Rica',
|
||||
},
|
||||
{
|
||||
value: 'CI',
|
||||
name: "Cote d'Ivoire",
|
||||
},
|
||||
{
|
||||
value: 'HR',
|
||||
name: 'Croatia',
|
||||
},
|
||||
{
|
||||
value: 'CU',
|
||||
name: 'Cuba',
|
||||
},
|
||||
{
|
||||
value: 'CW',
|
||||
name: 'Curaçao',
|
||||
},
|
||||
{
|
||||
value: 'CY',
|
||||
name: 'Cyprus',
|
||||
},
|
||||
{
|
||||
value: 'CZ',
|
||||
name: 'Czechia',
|
||||
},
|
||||
{
|
||||
value: 'DK',
|
||||
name: 'Denmark',
|
||||
},
|
||||
{
|
||||
value: 'DJ',
|
||||
name: 'Djibouti',
|
||||
},
|
||||
{
|
||||
value: 'DM',
|
||||
name: 'Dominica',
|
||||
},
|
||||
{
|
||||
value: 'DO',
|
||||
name: 'Dominican Republic',
|
||||
},
|
||||
{
|
||||
value: 'EC',
|
||||
name: 'Ecuador',
|
||||
},
|
||||
{
|
||||
value: 'EG',
|
||||
name: 'Egypt',
|
||||
},
|
||||
{
|
||||
value: 'SV',
|
||||
name: 'El Salvador',
|
||||
},
|
||||
{
|
||||
value: 'GQ',
|
||||
name: 'Equatorial Guinea',
|
||||
},
|
||||
{
|
||||
value: 'ER',
|
||||
name: 'Eritrea',
|
||||
},
|
||||
{
|
||||
value: 'EE',
|
||||
name: 'Estonia',
|
||||
},
|
||||
{
|
||||
value: 'SZ',
|
||||
name: 'Eswatini',
|
||||
},
|
||||
{
|
||||
value: 'ET',
|
||||
name: 'Ethiopia',
|
||||
},
|
||||
{
|
||||
value: 'FK',
|
||||
name: 'Falkland Islands (Malvinas)',
|
||||
},
|
||||
{
|
||||
value: 'FO',
|
||||
name: 'Faroe Islands',
|
||||
},
|
||||
{
|
||||
value: 'FJ',
|
||||
name: 'Fiji',
|
||||
},
|
||||
{
|
||||
value: 'FI',
|
||||
name: 'Finland',
|
||||
},
|
||||
{
|
||||
value: 'FR',
|
||||
name: 'France',
|
||||
},
|
||||
{
|
||||
value: 'GF',
|
||||
name: 'French Guiana',
|
||||
},
|
||||
{
|
||||
value: 'PF',
|
||||
name: 'French Polynesia',
|
||||
},
|
||||
{
|
||||
value: 'TF',
|
||||
name: 'French Southern Territories',
|
||||
},
|
||||
{
|
||||
value: 'GA',
|
||||
name: 'Gabon',
|
||||
},
|
||||
{
|
||||
value: 'GM',
|
||||
name: 'Gambia',
|
||||
},
|
||||
{
|
||||
value: 'GE',
|
||||
name: 'Georgia',
|
||||
},
|
||||
{
|
||||
value: 'DE',
|
||||
name: 'Germany',
|
||||
},
|
||||
{
|
||||
value: 'GH',
|
||||
name: 'Ghana',
|
||||
},
|
||||
{
|
||||
value: 'GI',
|
||||
name: 'Gibraltar',
|
||||
},
|
||||
{
|
||||
value: 'GR',
|
||||
name: 'Greece',
|
||||
},
|
||||
{
|
||||
value: 'GL',
|
||||
name: 'Greenland',
|
||||
},
|
||||
{
|
||||
value: 'GD',
|
||||
name: 'Grenada',
|
||||
},
|
||||
{
|
||||
value: 'GP',
|
||||
name: 'Guadeloupe',
|
||||
},
|
||||
{
|
||||
value: 'GU',
|
||||
name: 'Guam',
|
||||
},
|
||||
{
|
||||
value: 'GT',
|
||||
name: 'Guatemala',
|
||||
},
|
||||
{
|
||||
value: 'GG',
|
||||
name: 'Guernsey',
|
||||
},
|
||||
{
|
||||
value: 'GN',
|
||||
name: 'Guinea',
|
||||
},
|
||||
{
|
||||
value: 'GW',
|
||||
name: 'Guinea-Bissau',
|
||||
},
|
||||
{
|
||||
value: 'GY',
|
||||
name: 'Guyana',
|
||||
},
|
||||
{
|
||||
value: 'HT',
|
||||
name: 'Haiti',
|
||||
},
|
||||
{
|
||||
value: 'HM',
|
||||
name: 'Heard Island and McDonald Islands',
|
||||
},
|
||||
{
|
||||
value: 'VA',
|
||||
name: 'Holy See',
|
||||
},
|
||||
{
|
||||
value: 'HN',
|
||||
name: 'Honduras',
|
||||
},
|
||||
{
|
||||
value: 'HK',
|
||||
name: 'Hong Kong',
|
||||
},
|
||||
{
|
||||
value: 'HU',
|
||||
name: 'Hungary',
|
||||
},
|
||||
{
|
||||
value: 'IS',
|
||||
name: 'Iceland',
|
||||
},
|
||||
{
|
||||
value: 'IN',
|
||||
name: 'India',
|
||||
},
|
||||
{
|
||||
value: 'ID',
|
||||
name: 'Indonesia',
|
||||
},
|
||||
{
|
||||
value: 'IR',
|
||||
name: 'Iran, Islamic Republic Of',
|
||||
},
|
||||
{
|
||||
value: 'IQ',
|
||||
name: 'Iraq',
|
||||
},
|
||||
{
|
||||
value: 'IE',
|
||||
name: 'Ireland',
|
||||
},
|
||||
{
|
||||
value: 'IM',
|
||||
name: 'Isle of Man',
|
||||
},
|
||||
{
|
||||
value: 'IL',
|
||||
name: 'Israel',
|
||||
},
|
||||
{
|
||||
value: 'IT',
|
||||
name: 'Italy',
|
||||
},
|
||||
{
|
||||
value: 'JM',
|
||||
name: 'Jamaica',
|
||||
},
|
||||
{
|
||||
value: 'JP',
|
||||
name: 'Japan',
|
||||
},
|
||||
{
|
||||
value: 'JE',
|
||||
name: 'Jersey',
|
||||
},
|
||||
{
|
||||
value: 'JO',
|
||||
name: 'Jordan',
|
||||
},
|
||||
{
|
||||
value: 'KZ',
|
||||
name: 'Kazakhstan',
|
||||
},
|
||||
{
|
||||
value: 'KE',
|
||||
name: 'Kenya',
|
||||
},
|
||||
{
|
||||
value: 'KI',
|
||||
name: 'Kiribati',
|
||||
},
|
||||
{
|
||||
value: 'KP',
|
||||
name: "Korea, Democratic People's Republic Of",
|
||||
},
|
||||
{
|
||||
value: 'KR',
|
||||
name: 'Korea, Republic Of',
|
||||
},
|
||||
{
|
||||
value: 'KW',
|
||||
name: 'Kuwait',
|
||||
},
|
||||
{
|
||||
value: 'KG',
|
||||
name: 'Kyrgyzstan',
|
||||
},
|
||||
{
|
||||
value: 'LA',
|
||||
name: "Lao People's Democratic Republic",
|
||||
},
|
||||
{
|
||||
value: 'LV',
|
||||
name: 'Latvia',
|
||||
},
|
||||
{
|
||||
value: 'LB',
|
||||
name: 'Lebanon',
|
||||
},
|
||||
{
|
||||
value: 'LS',
|
||||
name: 'Lesotho',
|
||||
},
|
||||
{
|
||||
value: 'LR',
|
||||
name: 'Liberia',
|
||||
},
|
||||
{
|
||||
value: 'LY',
|
||||
name: 'Libya',
|
||||
},
|
||||
{
|
||||
value: 'LI',
|
||||
name: 'Liechtenstein',
|
||||
},
|
||||
{
|
||||
value: 'LT',
|
||||
name: 'Lithuania',
|
||||
},
|
||||
{
|
||||
value: 'LU',
|
||||
name: 'Luxembourg',
|
||||
},
|
||||
{
|
||||
value: 'MO',
|
||||
name: 'Macao',
|
||||
},
|
||||
{
|
||||
value: 'MG',
|
||||
name: 'Madagascar',
|
||||
},
|
||||
{
|
||||
value: 'MW',
|
||||
name: 'Malawi',
|
||||
},
|
||||
{
|
||||
value: 'MY',
|
||||
name: 'Malaysia',
|
||||
},
|
||||
{
|
||||
value: 'MV',
|
||||
name: 'Maldives',
|
||||
},
|
||||
{
|
||||
value: 'ML',
|
||||
name: 'Mali',
|
||||
},
|
||||
{
|
||||
value: 'MT',
|
||||
name: 'Malta',
|
||||
},
|
||||
{
|
||||
value: 'MH',
|
||||
name: 'Marshall Islands',
|
||||
},
|
||||
{
|
||||
value: 'MQ',
|
||||
name: 'Martinique',
|
||||
},
|
||||
{
|
||||
value: 'MR',
|
||||
name: 'Mauritania',
|
||||
},
|
||||
{
|
||||
value: 'MU',
|
||||
name: 'Mauritius',
|
||||
},
|
||||
{
|
||||
value: 'YT',
|
||||
name: 'Mayotte',
|
||||
},
|
||||
{
|
||||
value: 'MX',
|
||||
name: 'Mexico',
|
||||
},
|
||||
{
|
||||
value: 'FM',
|
||||
name: 'Micronesia, Federated States Of',
|
||||
},
|
||||
{
|
||||
value: 'MD',
|
||||
name: 'Moldova, Republic Of',
|
||||
},
|
||||
{
|
||||
value: 'MC',
|
||||
name: 'Monaco',
|
||||
},
|
||||
{
|
||||
value: 'MN',
|
||||
name: 'Mongolia',
|
||||
},
|
||||
{
|
||||
value: 'ME',
|
||||
name: 'Montenegro',
|
||||
},
|
||||
{
|
||||
value: 'MS',
|
||||
name: 'Montserrat',
|
||||
},
|
||||
{
|
||||
value: 'MA',
|
||||
name: 'Morocco',
|
||||
},
|
||||
{
|
||||
value: 'MZ',
|
||||
name: 'Mozambique',
|
||||
},
|
||||
{
|
||||
value: 'MM',
|
||||
name: 'Myanmar',
|
||||
},
|
||||
{
|
||||
value: 'NA',
|
||||
name: 'Namibia',
|
||||
},
|
||||
{
|
||||
value: 'NR',
|
||||
name: 'Nauru',
|
||||
},
|
||||
{
|
||||
value: 'NP',
|
||||
name: 'Nepal',
|
||||
},
|
||||
{
|
||||
value: 'NL',
|
||||
name: 'Netherlands, Kingdom of The',
|
||||
},
|
||||
{
|
||||
value: 'NC',
|
||||
name: 'New Caledonia',
|
||||
},
|
||||
{
|
||||
value: 'NZ',
|
||||
name: 'New Zealand',
|
||||
},
|
||||
{
|
||||
value: 'NI',
|
||||
name: 'Nicaragua',
|
||||
},
|
||||
{
|
||||
value: 'NE',
|
||||
name: 'Niger',
|
||||
},
|
||||
{
|
||||
value: 'NG',
|
||||
name: 'Nigeria',
|
||||
},
|
||||
{
|
||||
value: 'NU',
|
||||
name: 'Niue',
|
||||
},
|
||||
{
|
||||
value: 'NF',
|
||||
name: 'Norfolk Island',
|
||||
},
|
||||
{
|
||||
value: 'MK',
|
||||
name: 'North Macedonia',
|
||||
},
|
||||
{
|
||||
value: 'MP',
|
||||
name: 'Northern Mariana Islands',
|
||||
},
|
||||
{
|
||||
value: 'NO',
|
||||
name: 'Norway',
|
||||
},
|
||||
{
|
||||
value: 'OM',
|
||||
name: 'Oman',
|
||||
},
|
||||
{
|
||||
value: 'PK',
|
||||
name: 'Pakistan',
|
||||
},
|
||||
{
|
||||
value: 'PW',
|
||||
name: 'Palau',
|
||||
},
|
||||
{
|
||||
value: 'PS',
|
||||
name: 'Palestine, State Of',
|
||||
},
|
||||
{
|
||||
value: 'PA',
|
||||
name: 'Panama',
|
||||
},
|
||||
{
|
||||
value: 'PG',
|
||||
name: 'Papua New Guinea',
|
||||
},
|
||||
{
|
||||
value: 'PY',
|
||||
name: 'Paraguay',
|
||||
},
|
||||
{
|
||||
value: 'PE',
|
||||
name: 'Peru',
|
||||
},
|
||||
{
|
||||
value: 'PH',
|
||||
name: 'Philippines',
|
||||
},
|
||||
{
|
||||
value: 'PN',
|
||||
name: 'Pitcairn',
|
||||
},
|
||||
{
|
||||
value: 'PL',
|
||||
name: 'Poland',
|
||||
},
|
||||
{
|
||||
value: 'PT',
|
||||
name: 'Portugal',
|
||||
},
|
||||
{
|
||||
value: 'PR',
|
||||
name: 'Puerto Rico',
|
||||
},
|
||||
{
|
||||
value: 'QA',
|
||||
name: 'Qatar',
|
||||
},
|
||||
{
|
||||
value: 'RE',
|
||||
name: 'Réunion',
|
||||
},
|
||||
{
|
||||
value: 'RO',
|
||||
name: 'Romania',
|
||||
},
|
||||
{
|
||||
value: 'RU',
|
||||
name: 'Russian Federation',
|
||||
},
|
||||
{
|
||||
value: 'RW',
|
||||
name: 'Rwanda',
|
||||
},
|
||||
{
|
||||
value: 'BL',
|
||||
name: 'Saint Barthelemy',
|
||||
},
|
||||
{
|
||||
value: 'SH',
|
||||
name: 'Saint Helena, Ascension and Tristan Da Cunha',
|
||||
},
|
||||
{
|
||||
value: 'KN',
|
||||
name: 'Saint Kitts and Nevis',
|
||||
},
|
||||
{
|
||||
value: 'LC',
|
||||
name: 'Saint Lucia',
|
||||
},
|
||||
{
|
||||
value: 'MF',
|
||||
name: 'Saint Martin (French Part)',
|
||||
},
|
||||
{
|
||||
value: 'PM',
|
||||
name: 'Saint Pierre and Miquelon',
|
||||
},
|
||||
{
|
||||
value: 'VC',
|
||||
name: 'Saint Vincent and the Grenadines',
|
||||
},
|
||||
{
|
||||
value: 'WS',
|
||||
name: 'Samoa',
|
||||
},
|
||||
{
|
||||
value: 'SM',
|
||||
name: 'San Marino',
|
||||
},
|
||||
{
|
||||
value: 'ST',
|
||||
name: 'Sao Tome and Principe',
|
||||
},
|
||||
{
|
||||
value: 'SA',
|
||||
name: 'Saudi Arabia',
|
||||
},
|
||||
{
|
||||
value: 'SN',
|
||||
name: 'Senegal',
|
||||
},
|
||||
{
|
||||
value: 'RS',
|
||||
name: 'Serbia',
|
||||
},
|
||||
{
|
||||
value: 'SC',
|
||||
name: 'Seychelles',
|
||||
},
|
||||
{
|
||||
value: 'SL',
|
||||
name: 'Sierra Leone',
|
||||
},
|
||||
{
|
||||
value: 'SG',
|
||||
name: 'Singapore',
|
||||
},
|
||||
{
|
||||
value: 'SX',
|
||||
name: 'Sint Maarten (Dutch Part)',
|
||||
},
|
||||
{
|
||||
value: 'SK',
|
||||
name: 'Slovakia',
|
||||
},
|
||||
{
|
||||
value: 'SI',
|
||||
name: 'Slovenia',
|
||||
},
|
||||
{
|
||||
value: 'SB',
|
||||
name: 'Solomon Islands',
|
||||
},
|
||||
{
|
||||
value: 'SO',
|
||||
name: 'Somalia',
|
||||
},
|
||||
{
|
||||
value: 'ZA',
|
||||
name: 'South Africa',
|
||||
},
|
||||
{
|
||||
value: 'GS',
|
||||
name: 'South Georgia and the South Sandwich Islands',
|
||||
},
|
||||
{
|
||||
value: 'SS',
|
||||
name: 'South Sudan',
|
||||
},
|
||||
{
|
||||
value: 'ES',
|
||||
name: 'Spain',
|
||||
},
|
||||
{
|
||||
value: 'LK',
|
||||
name: 'Sri Lanka',
|
||||
},
|
||||
{
|
||||
value: 'SD',
|
||||
name: 'Sudan',
|
||||
},
|
||||
{
|
||||
value: 'SR',
|
||||
name: 'Suriname',
|
||||
},
|
||||
{
|
||||
value: 'SJ',
|
||||
name: 'Svalbard and Jan Mayen',
|
||||
},
|
||||
{
|
||||
value: 'SE',
|
||||
name: 'Sweden',
|
||||
},
|
||||
{
|
||||
value: 'CH',
|
||||
name: 'Switzerland',
|
||||
},
|
||||
{
|
||||
value: 'SY',
|
||||
name: 'Syrian Arab Republic',
|
||||
},
|
||||
{
|
||||
value: 'TW',
|
||||
name: 'Taiwan, Province of China',
|
||||
},
|
||||
{
|
||||
value: 'TJ',
|
||||
name: 'Tajikistan',
|
||||
},
|
||||
{
|
||||
value: 'TZ',
|
||||
name: 'Tanzania, United Republic Of',
|
||||
},
|
||||
{
|
||||
value: 'TH',
|
||||
name: 'Thailand',
|
||||
},
|
||||
{
|
||||
value: 'TL',
|
||||
name: 'Timor-Leste',
|
||||
},
|
||||
{
|
||||
value: 'TG',
|
||||
name: 'Togo',
|
||||
},
|
||||
{
|
||||
value: 'TK',
|
||||
name: 'Tokelau',
|
||||
},
|
||||
{
|
||||
value: 'TO',
|
||||
name: 'Tonga',
|
||||
},
|
||||
{
|
||||
value: 'TT',
|
||||
name: 'Trinidad and Tobago',
|
||||
},
|
||||
{
|
||||
value: 'TN',
|
||||
name: 'Tunisia',
|
||||
},
|
||||
{
|
||||
value: 'TR',
|
||||
name: 'Turkey',
|
||||
},
|
||||
{
|
||||
value: 'TM',
|
||||
name: 'Turkmenistan',
|
||||
},
|
||||
{
|
||||
value: 'TC',
|
||||
name: 'Turks and Caicos Islands',
|
||||
},
|
||||
{
|
||||
value: 'TV',
|
||||
name: 'Tuvalu',
|
||||
},
|
||||
{
|
||||
value: 'UG',
|
||||
name: 'Uganda',
|
||||
},
|
||||
{
|
||||
value: 'UA',
|
||||
name: 'Ukraine',
|
||||
},
|
||||
{
|
||||
value: 'AE',
|
||||
name: 'United Arab Emirates',
|
||||
},
|
||||
{
|
||||
value: 'GB',
|
||||
name: 'United Kingdom of Great Britain and Northern Ireland',
|
||||
},
|
||||
{
|
||||
value: 'UM',
|
||||
name: 'United States Minor Outlying Islands',
|
||||
},
|
||||
{
|
||||
value: 'US',
|
||||
name: 'United States of America',
|
||||
},
|
||||
{
|
||||
value: 'UY',
|
||||
name: 'Uruguay',
|
||||
},
|
||||
{
|
||||
value: 'UZ',
|
||||
name: 'Uzbekistan',
|
||||
},
|
||||
{
|
||||
value: 'VU',
|
||||
name: 'Vanuatu',
|
||||
},
|
||||
{
|
||||
value: 'VE',
|
||||
name: 'Venezuela, Bolivarian Republic Of',
|
||||
},
|
||||
{
|
||||
value: 'VN',
|
||||
name: 'Viet Nam',
|
||||
},
|
||||
{
|
||||
value: 'VG',
|
||||
name: 'Virgin Islands (British)',
|
||||
},
|
||||
{
|
||||
value: 'VI',
|
||||
name: 'Virgin Islands (U.S.)',
|
||||
},
|
||||
{
|
||||
value: 'WF',
|
||||
name: 'Wallis and Futuna',
|
||||
},
|
||||
{
|
||||
value: 'EH',
|
||||
name: 'Western Sahara',
|
||||
},
|
||||
{
|
||||
value: 'YE',
|
||||
name: 'Yemen',
|
||||
},
|
||||
{
|
||||
value: 'ZM',
|
||||
name: 'Zambia',
|
||||
},
|
||||
{
|
||||
value: 'ZW',
|
||||
name: 'Zimbabwe',
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
FieldType,
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchResult,
|
||||
ResourceMapperField,
|
||||
ResourceMapperFields,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { AgentsListResponse } from '../actions/agent/agent.types';
|
||||
import { getAgentDetails } from '../actions/agent/agent.utils';
|
||||
import { BASE_URL_V2 } from '../constants';
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
const VALID_FIELD_TYPES: readonly FieldType[] = [
|
||||
'boolean',
|
||||
'number',
|
||||
'string',
|
||||
'dateTime',
|
||||
'time',
|
||||
'array',
|
||||
'object',
|
||||
'options',
|
||||
] as const;
|
||||
|
||||
function isValidFieldType(value: string): value is FieldType {
|
||||
return VALID_FIELD_TYPES.includes(value as FieldType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for Airtop agents available in the user's account.
|
||||
* Used as the searchListMethod for the agent resourceLocator.
|
||||
*/
|
||||
export async function listSearchAgents(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const qs = {
|
||||
limit: 50,
|
||||
enabled: true,
|
||||
published: true,
|
||||
createdByMe: true,
|
||||
name: filter ?? '',
|
||||
};
|
||||
|
||||
const response = await apiRequest.call<
|
||||
ILoadOptionsFunctions,
|
||||
['GET', string, IDataObject, IDataObject],
|
||||
Promise<AgentsListResponse>
|
||||
>(this, 'GET', `${BASE_URL_V2}/agents`, {}, qs);
|
||||
|
||||
const agents = response.agents ?? [];
|
||||
|
||||
const results = agents
|
||||
.map((agent) => ({
|
||||
name: agent.name,
|
||||
value: agent.id,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the agent parameters to the resource mapper fields.
|
||||
* Used as the resourceMapperMethod for the agent parameters dropdown.
|
||||
*/
|
||||
export async function agentsResourceMapping(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<ResourceMapperFields> {
|
||||
const agentId = this.getCurrentNodeParameter('agentId') as {
|
||||
mode: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
if (!agentId?.value) {
|
||||
return { fields: [] };
|
||||
}
|
||||
|
||||
const response = await getAgentDetails.call(this, agentId.value);
|
||||
|
||||
if (!response?.versionData?.configVarsSchema?.properties) {
|
||||
return { fields: [] };
|
||||
}
|
||||
|
||||
const properties = response.versionData.configVarsSchema.properties;
|
||||
const requiredFields = response.versionData.configVarsSchema?.required ?? [];
|
||||
|
||||
const fields: ResourceMapperField[] = Object.entries(properties)
|
||||
.map(([name, prop]) => {
|
||||
const isRequired = requiredFields.includes(name);
|
||||
const fieldType = isValidFieldType(prop.type) ? prop.type : 'string';
|
||||
return {
|
||||
id: name,
|
||||
displayName: `${name}${isRequired ? ' (required)' : ''}`,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: fieldType,
|
||||
required: isRequired,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
return { fields };
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
|
||||
import * as run from '../../../actions/agent/run.operation';
|
||||
import { ERROR_MESSAGES, BASE_URL_V2, AIRTOP_HOOKS_BASE_URL } from '../../../constants';
|
||||
import * as methods from '../../../methods';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const AGENTS_ENDPOINT = `${BASE_URL_V2}/agents`;
|
||||
const AGENTS_HOOKS_ENDPOINT = `${AIRTOP_HOOKS_BASE_URL}/agents`;
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'agent',
|
||||
operation: 'run',
|
||||
webhookUrl: 'https://api.airtop.ai/api/hooks/agents/test-agent-123/webhooks/test-webhook',
|
||||
agentParameters: '{"key": "value"}',
|
||||
awaitExecution: true,
|
||||
timeout: 600,
|
||||
};
|
||||
|
||||
const mockInvocationResponse = {
|
||||
invocationId: 'invocation-123',
|
||||
};
|
||||
|
||||
const mockAgentStatusResponseWithOutput = {
|
||||
status: 'Completed' as const,
|
||||
output: {
|
||||
result: 'success',
|
||||
data: { test: 'data' },
|
||||
},
|
||||
};
|
||||
|
||||
const mockAgentStatusResponseRunning = {
|
||||
status: 'Running' as const,
|
||||
};
|
||||
|
||||
const mockAgentsListResponse = {
|
||||
agents: [
|
||||
{ id: 'agent-1', name: 'Test Agent 1', enabled: true },
|
||||
{ id: 'agent-2', name: 'Test Agent 2', enabled: true },
|
||||
{ id: 'agent-3', name: 'Another Agent', enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
const mockAgentDetailsResponse = {
|
||||
id: 'test-agent-123',
|
||||
name: 'Test Agent',
|
||||
enabled: true,
|
||||
publishedVersion: 1,
|
||||
webhookId: 'test-webhook',
|
||||
versionData: {
|
||||
configVarsSchema: {
|
||||
properties: {
|
||||
url: { type: 'string', description: 'The URL to process' },
|
||||
maxResults: { type: 'number', description: 'Maximum results to return' },
|
||||
includeMetadata: { type: 'boolean', description: 'Include metadata in response' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const createMockLoadOptionsFunction = (
|
||||
nodeParameters: Record<string, unknown> = {},
|
||||
): ILoadOptionsFunctions => {
|
||||
return {
|
||||
getCurrentNodeParameter(parameterName: string) {
|
||||
return nodeParameters[parameterName];
|
||||
},
|
||||
getCredentials: jest.fn(),
|
||||
getNode: () => ({
|
||||
id: '1',
|
||||
name: 'Airtop node',
|
||||
typeVersion: 1,
|
||||
type: 'n8n-nodes-base.airtop',
|
||||
position: [10, 10],
|
||||
parameters: {},
|
||||
}),
|
||||
} as unknown as ILoadOptionsFunctions;
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, agent run operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should list available agents', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentsListResponse);
|
||||
|
||||
const mockLoadOptions = createMockLoadOptionsFunction();
|
||||
const result = await methods.listSearchAgents.call(mockLoadOptions, '');
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
AGENTS_ENDPOINT,
|
||||
{},
|
||||
{ createdByMe: true, limit: 50, enabled: true, published: true, name: '' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'Another Agent', value: 'agent-3' },
|
||||
{ name: 'Test Agent 1', value: 'agent-1' },
|
||||
{ name: 'Test Agent 2', value: 'agent-2' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should get agent input parameters schema for selected agent ID', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentDetailsResponse);
|
||||
|
||||
const mockLoadOptions = createMockLoadOptionsFunction({
|
||||
agentId: { mode: 'list', value: 'test-agent-123' },
|
||||
});
|
||||
|
||||
const result = await methods.agentsResourceMapping.call(mockLoadOptions);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('GET', `${AGENTS_ENDPOINT}/test-agent-123`);
|
||||
|
||||
expect(result).toEqual({
|
||||
fields: [
|
||||
{
|
||||
id: 'includeMetadata',
|
||||
displayName: 'includeMetadata',
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'maxResults',
|
||||
displayName: 'maxResults',
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'number',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
displayName: 'url (required)',
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate required agent parameters', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentDetailsResponse);
|
||||
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
agentId: {
|
||||
mode: 'id',
|
||||
value: 'test-agent-123',
|
||||
},
|
||||
agentParameters: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {
|
||||
maxResults: 10, // url is required but missing
|
||||
},
|
||||
schema: [
|
||||
{ id: 'url', displayName: 'url (required)', type: 'string', required: true },
|
||||
{ id: 'maxResults', displayName: 'maxResults', type: 'number', required: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await expect(run.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
'Missing required parameters: url',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return invocationId without waiting for agent completion', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
// First call: getAgentDetails
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentDetailsResponse);
|
||||
// Second call: invoke agent webhook
|
||||
apiRequestMock.mockResolvedValueOnce(mockInvocationResponse);
|
||||
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
agentId: {
|
||||
mode: 'id',
|
||||
value: 'test-agent-123',
|
||||
},
|
||||
agentParameters: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {},
|
||||
schema: [],
|
||||
},
|
||||
awaitExecution: false,
|
||||
};
|
||||
|
||||
const result = await run.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First call should be getAgentDetails
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(1, 'GET', `${AGENTS_ENDPOINT}/test-agent-123`);
|
||||
|
||||
// Second call should be the invocation
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'POST',
|
||||
`${AGENTS_HOOKS_ENDPOINT}/test-agent-123/webhooks/test-webhook`,
|
||||
{ configVars: {} },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
invocationId: 'invocation-123',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should wait for agent until response contains an output', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
|
||||
// Mock getAgentDetails
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentDetailsResponse);
|
||||
|
||||
// Mock the initial invocation request
|
||||
apiRequestMock.mockResolvedValueOnce(mockInvocationResponse);
|
||||
|
||||
// Mock the first status check (still running, no output)
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentStatusResponseRunning);
|
||||
|
||||
// Mock the second status check (completed with output)
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentStatusResponseWithOutput);
|
||||
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
agentId: {
|
||||
mode: 'id',
|
||||
value: 'test-agent-123',
|
||||
},
|
||||
agentParameters: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {},
|
||||
schema: [],
|
||||
},
|
||||
awaitExecution: true,
|
||||
timeout: 600,
|
||||
};
|
||||
|
||||
const result = await run.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
// Should have called apiRequest 4 times: 1 for getAgentDetails + 1 for invocation + 2 for status checks
|
||||
expect(apiRequestMock).toHaveBeenCalledTimes(4);
|
||||
|
||||
// First call should be getAgentDetails
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(1, 'GET', `${AGENTS_ENDPOINT}/test-agent-123`);
|
||||
|
||||
// Second call should be the invocation
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'POST',
|
||||
`${AGENTS_HOOKS_ENDPOINT}/test-agent-123/webhooks/test-webhook`,
|
||||
{ configVars: {} },
|
||||
);
|
||||
|
||||
// Third and fourth calls should be status checks
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'GET',
|
||||
`${AGENTS_HOOKS_ENDPOINT}/test-agent-123/invocations/invocation-123/result`,
|
||||
);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'GET',
|
||||
`${AGENTS_HOOKS_ENDPOINT}/test-agent-123/invocations/invocation-123/result`,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
invocationId: 'invocation-123',
|
||||
status: 'Completed',
|
||||
output: {
|
||||
result: 'success',
|
||||
data: { test: 'data' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw an error if timeout is less than 10 seconds', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
timeout: 5, // Less than 10 seconds
|
||||
};
|
||||
|
||||
await expect(run.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.AGENT_TIMEOUT_INVALID,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty results when no agents are available', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce({ agents: [] });
|
||||
|
||||
const mockLoadOptions = createMockLoadOptionsFunction();
|
||||
const result = await methods.listSearchAgents.call(mockLoadOptions, '');
|
||||
|
||||
expect(result).toEqual({ results: [] });
|
||||
});
|
||||
|
||||
it('should return empty fields when agent has no parameters schema', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce({
|
||||
id: 'test-agent-123',
|
||||
name: 'Test Agent',
|
||||
enabled: true,
|
||||
publishedVersion: 1,
|
||||
webhookId: 'test-webhook',
|
||||
versionData: {},
|
||||
});
|
||||
|
||||
const mockLoadOptions = createMockLoadOptionsFunction({
|
||||
agentId: { mode: 'list', value: 'test-agent-123' },
|
||||
});
|
||||
|
||||
const result = await methods.agentsResourceMapping.call(mockLoadOptions);
|
||||
|
||||
expect(result).toEqual({ fields: [] });
|
||||
});
|
||||
|
||||
it('should filter agents by name when search filter is provided', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentsListResponse);
|
||||
|
||||
const mockLoadOptions = createMockLoadOptionsFunction();
|
||||
await methods.listSearchAgents.call(mockLoadOptions, 'Test');
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
AGENTS_ENDPOINT,
|
||||
{},
|
||||
{ createdByMe: true, limit: 50, enabled: true, published: true, name: 'Test' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should wrap agent parameters in configVars when executing', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
// First call: getAgentDetails
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentDetailsResponse);
|
||||
// Second call: invoke agent webhook
|
||||
apiRequestMock.mockResolvedValueOnce(mockInvocationResponse);
|
||||
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
agentId: {
|
||||
mode: 'id',
|
||||
value: 'test-agent-123',
|
||||
},
|
||||
agentParameters: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {
|
||||
url: 'https://example.com',
|
||||
maxResults: 10,
|
||||
},
|
||||
schema: [
|
||||
{ id: 'url', displayName: 'url (required)', type: 'string', required: true },
|
||||
{ id: 'maxResults', displayName: 'maxResults', type: 'number', required: false },
|
||||
],
|
||||
},
|
||||
awaitExecution: false,
|
||||
};
|
||||
|
||||
await run.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First call should be getAgentDetails
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(1, 'GET', `${AGENTS_ENDPOINT}/test-agent-123`);
|
||||
|
||||
// Second call should be the invocation with configVars
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'POST',
|
||||
`${AGENTS_HOOKS_ENDPOINT}/test-agent-123/webhooks/test-webhook`,
|
||||
{
|
||||
configVars: {
|
||||
url: 'https://example.com',
|
||||
maxResults: 10,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass all required parameters successfully', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentDetailsResponse);
|
||||
apiRequestMock.mockResolvedValueOnce(mockInvocationResponse);
|
||||
apiRequestMock.mockResolvedValueOnce(mockAgentStatusResponseWithOutput);
|
||||
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
agentId: {
|
||||
mode: 'id',
|
||||
value: 'test-agent-123',
|
||||
},
|
||||
agentParameters: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {
|
||||
url: 'https://example.com',
|
||||
maxResults: 10,
|
||||
includeMetadata: true,
|
||||
},
|
||||
schema: [
|
||||
{ id: 'url', displayName: 'url (required)', type: 'string', required: true },
|
||||
{ id: 'maxResults', displayName: 'maxResults', type: 'number', required: false },
|
||||
{
|
||||
id: 'includeMetadata',
|
||||
displayName: 'includeMetadata',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
awaitExecution: true,
|
||||
};
|
||||
|
||||
const result = await run.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
invocationId: 'invocation-123',
|
||||
status: 'Completed',
|
||||
output: {
|
||||
result: 'success',
|
||||
data: { test: 'data' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,283 @@
|
||||
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: {
|
||||
experimental: {
|
||||
includeVisualAnalysis: 'disabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
experimental: {
|
||||
includeVisualAnalysis: 'disabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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: {
|
||||
experimental: {
|
||||
includeVisualAnalysis: 'disabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
it("should query the page with 'includeVisualAnalysis' enabled", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
prompt: 'List the colors of the products on the page',
|
||||
additionalFields: {
|
||||
includeVisualAnalysis: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await query.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/page-query',
|
||||
{
|
||||
prompt: 'List the colors of the products on the page',
|
||||
configuration: {
|
||||
experimental: {
|
||||
includeVisualAnalysis: 'enabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should query the page with 'includeVisualAnalysis' disabled", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
prompt: 'How many products are on the page and what is their price range?',
|
||||
additionalFields: {
|
||||
includeVisualAnalysis: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await query.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.shouldCreateNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.createSessionAndWindow).not.toHaveBeenCalled();
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/page-query',
|
||||
{
|
||||
prompt: 'How many products are on the page and what is their price range?',
|
||||
configuration: {
|
||||
experimental: {
|
||||
includeVisualAnalysis: 'disabled',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
data: mockResponse.data,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as deleteFile from '../../../actions/file/delete.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'file',
|
||||
operation: 'deleteFile',
|
||||
sessionId: 'test-session-123',
|
||||
fileId: 'file-123',
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, delete file operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should delete file successfully', async () => {
|
||||
const result = await deleteFile.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('DELETE', '/files/file-123');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
data: {
|
||||
message: 'File deleted successfully',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when fileId is empty', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
fileId: '',
|
||||
};
|
||||
|
||||
await expect(
|
||||
deleteFile.execute.call(createMockExecuteFunction(nodeParameters), 0),
|
||||
).rejects.toThrow(ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'File ID'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import * as get from '../../../actions/file/get.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'file',
|
||||
operation: 'get',
|
||||
sessionId: 'test-session-123',
|
||||
fileId: 'file-123',
|
||||
};
|
||||
|
||||
const mockFileResponse = {
|
||||
data: {
|
||||
id: 'file-123',
|
||||
fileName: 'test-file.pdf',
|
||||
status: 'available',
|
||||
downloadUrl: 'https://api.airtop.com/files/file-123/download',
|
||||
},
|
||||
};
|
||||
|
||||
const mockBinaryBuffer = Buffer.from('mock-binary-data');
|
||||
|
||||
const mockPreparedBinaryData = {
|
||||
mimeType: 'application/pdf',
|
||||
fileType: 'pdf',
|
||||
fileName: 'test-file.pdf',
|
||||
data: 'mock-base64-data',
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, get file operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get file details successfully', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockFileResponse);
|
||||
|
||||
const result = await get.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('GET', '/files/file-123');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
...mockFileResponse,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should output file with binary data when specified', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockFileResponse);
|
||||
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
outputBinaryFile: true,
|
||||
};
|
||||
|
||||
const mockExecuteFunction = createMockExecuteFunction(nodeParameters);
|
||||
|
||||
mockExecuteFunction.helpers.httpRequest = jest.fn().mockResolvedValue(mockBinaryBuffer);
|
||||
mockExecuteFunction.helpers.prepareBinaryData = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockPreparedBinaryData);
|
||||
|
||||
const result = await get.execute.call(mockExecuteFunction, 0);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('GET', '/files/file-123');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
...mockFileResponse,
|
||||
},
|
||||
binary: { data: mockPreparedBinaryData },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when fileId is empty', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
fileId: '',
|
||||
};
|
||||
|
||||
await expect(get.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'File ID'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as getMany from '../../../actions/file/getMany.operation';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'file',
|
||||
operation: 'getMany',
|
||||
sessionId: 'test-session-123',
|
||||
returnAll: true,
|
||||
outputSingleItem: true,
|
||||
};
|
||||
|
||||
const mockFilesResponse = {
|
||||
data: {
|
||||
files: [
|
||||
{
|
||||
id: 'file-123',
|
||||
name: 'document1.pdf',
|
||||
size: 12345,
|
||||
contentType: 'application/pdf',
|
||||
createdAt: '2023-06-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'file-456',
|
||||
name: 'image1.jpg',
|
||||
size: 54321,
|
||||
contentType: 'image/jpeg',
|
||||
createdAt: '2023-06-16T11:45:00Z',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
hasMore: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockPaginatedResponse = {
|
||||
data: {
|
||||
files: [mockFilesResponse.data.files[0]],
|
||||
pagination: {
|
||||
hasMore: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, get many files operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get all files successfully', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockFilesResponse);
|
||||
|
||||
const result = await getMany.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/files',
|
||||
{},
|
||||
{
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
sessionIds: '',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
...mockFilesResponse,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle limited results', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockPaginatedResponse);
|
||||
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
returnAll: false,
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const result = await getMany.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/files',
|
||||
{},
|
||||
{
|
||||
limit: 1,
|
||||
sessionIds: '',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
...mockPaginatedResponse,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import * as helpers from '../../../actions/file/helpers';
|
||||
import * as GenericFunctions from '../../../GenericFunctions';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const mockFileCreateResponse = {
|
||||
data: {
|
||||
id: 'file-123',
|
||||
uploadUrl: 'https://upload.example.com/url',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the transport and other dependencies
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../GenericFunctions', () => {
|
||||
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
|
||||
return {
|
||||
...originalModule,
|
||||
waitForSessionEvent: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop file helpers', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
jest.unmock('../../../GenericFunctions');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(transport.apiRequest as jest.Mock).mockReset();
|
||||
(GenericFunctions.waitForSessionEvent as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
describe('requestAllFiles', () => {
|
||||
it('should request all files with pagination', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
const mockFilesResponse1 = {
|
||||
data: {
|
||||
files: [{ id: 'file-1' }, { id: 'file-2' }],
|
||||
pagination: { hasMore: true },
|
||||
},
|
||||
};
|
||||
|
||||
const mockFilesResponse2 = {
|
||||
data: {
|
||||
files: [{ id: 'file-3' }],
|
||||
pagination: { hasMore: false },
|
||||
},
|
||||
};
|
||||
|
||||
apiRequestMock
|
||||
.mockResolvedValueOnce(mockFilesResponse1)
|
||||
.mockResolvedValueOnce(mockFilesResponse2);
|
||||
|
||||
const result = await helpers.requestAllFiles.call(
|
||||
createMockExecuteFunction({}),
|
||||
'session-123',
|
||||
);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledTimes(2);
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'GET',
|
||||
'/files',
|
||||
{},
|
||||
{ offset: 0, limit: 100, sessionIds: 'session-123' },
|
||||
);
|
||||
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'GET',
|
||||
'/files',
|
||||
{},
|
||||
{ offset: 100, limit: 100, sessionIds: 'session-123' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
files: [{ id: 'file-1' }, { id: 'file-2' }, { id: 'file-3' }],
|
||||
pagination: { hasMore: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty response', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
const mockEmptyResponse = {
|
||||
data: {
|
||||
files: [],
|
||||
pagination: { hasMore: false },
|
||||
},
|
||||
};
|
||||
|
||||
apiRequestMock.mockResolvedValueOnce(mockEmptyResponse);
|
||||
|
||||
const result = await helpers.requestAllFiles.call(
|
||||
createMockExecuteFunction({}),
|
||||
'session-123',
|
||||
);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
files: [],
|
||||
pagination: { hasMore: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pollFileUntilAvailable', () => {
|
||||
it('should poll until file is available', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock
|
||||
.mockResolvedValueOnce({ data: { status: 'uploading' } })
|
||||
.mockResolvedValueOnce({ data: { status: 'available' } });
|
||||
|
||||
const pollPromise = helpers.pollFileUntilAvailable.call(
|
||||
createMockExecuteFunction({}),
|
||||
'file-123',
|
||||
1000,
|
||||
0,
|
||||
);
|
||||
|
||||
const result = await pollPromise;
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledTimes(2);
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('GET', '/files/file-123');
|
||||
expect(result).toBe('file-123');
|
||||
});
|
||||
|
||||
it('should throw timeout error if file never becomes available', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValue({ data: { status: 'processing' } });
|
||||
|
||||
const promise = helpers.pollFileUntilAvailable.call(
|
||||
createMockExecuteFunction({}),
|
||||
'file-123',
|
||||
0,
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAndUploadFile', () => {
|
||||
it('should create file entry, upload file, and poll until available', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock
|
||||
.mockResolvedValueOnce(mockFileCreateResponse)
|
||||
.mockResolvedValueOnce({ data: { status: 'available' } });
|
||||
|
||||
const mockExecuteFunction = createMockExecuteFunction({});
|
||||
const mockHttpRequest = jest.fn().mockResolvedValueOnce({});
|
||||
mockExecuteFunction.helpers.httpRequest = mockHttpRequest;
|
||||
const pollingFunctionMock = jest.fn().mockResolvedValueOnce(mockFileCreateResponse.data.id);
|
||||
|
||||
const result = await helpers.createAndUploadFile.call(
|
||||
mockExecuteFunction,
|
||||
'test.png',
|
||||
Buffer.from('test'),
|
||||
'customer_upload',
|
||||
pollingFunctionMock,
|
||||
);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', '/files', {
|
||||
fileName: 'test.png',
|
||||
fileType: 'customer_upload',
|
||||
});
|
||||
|
||||
expect(mockHttpRequest).toHaveBeenCalledWith({
|
||||
method: 'PUT',
|
||||
url: mockFileCreateResponse.data.uploadUrl,
|
||||
body: Buffer.from('test'),
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
|
||||
expect(pollingFunctionMock).toHaveBeenCalledWith(mockFileCreateResponse.data.id);
|
||||
|
||||
expect(result).toBe(mockFileCreateResponse.data.id);
|
||||
});
|
||||
|
||||
it('should throw error if file creation response is missing id or upload URL', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce({});
|
||||
|
||||
await expect(
|
||||
helpers.createAndUploadFile.call(
|
||||
createMockExecuteFunction({}),
|
||||
'test.pdf',
|
||||
Buffer.from('test'),
|
||||
'customer_upload',
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForFileInSession', () => {
|
||||
it('should resolve when file_upload_status event with available status is received', async () => {
|
||||
const waitForSessionEventMock = GenericFunctions.waitForSessionEvent as jest.Mock;
|
||||
const mockEvent = {
|
||||
event: 'file_upload_status',
|
||||
status: 'available',
|
||||
fileId: 'file-123',
|
||||
};
|
||||
waitForSessionEventMock.mockResolvedValueOnce(mockEvent);
|
||||
|
||||
const mockExecuteFunction = createMockExecuteFunction({});
|
||||
|
||||
await helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 1000);
|
||||
|
||||
expect(waitForSessionEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForSessionEventMock).toHaveBeenCalledWith(
|
||||
'session-123',
|
||||
expect.any(Function),
|
||||
1000,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when uploading a file with invalid file format', async () => {
|
||||
const waitForSessionEventMock = GenericFunctions.waitForSessionEvent as jest.Mock;
|
||||
const mockEvent = {
|
||||
event: 'file_upload_status',
|
||||
status: 'upload_failed',
|
||||
fileId: 'file-123',
|
||||
eventData: {
|
||||
error: 'Upload failed due to invalid file format',
|
||||
},
|
||||
};
|
||||
waitForSessionEventMock.mockResolvedValueOnce(mockEvent);
|
||||
|
||||
const mockExecuteFunction = createMockExecuteFunction({});
|
||||
|
||||
await expect(
|
||||
helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 1000),
|
||||
).rejects.toMatchObject({ description: 'Upload failed due to invalid file format' });
|
||||
});
|
||||
|
||||
it('should throw error when upload_failed status is received', async () => {
|
||||
const waitForSessionEventMock = GenericFunctions.waitForSessionEvent as jest.Mock;
|
||||
const mockEvent = {
|
||||
fileId: 'file-123',
|
||||
event: 'file_upload_status',
|
||||
status: 'upload_failed',
|
||||
eventData: {
|
||||
error: 'Upload failed for File ID: file-123',
|
||||
},
|
||||
};
|
||||
waitForSessionEventMock.mockResolvedValueOnce(mockEvent);
|
||||
|
||||
const mockExecuteFunction = createMockExecuteFunction({});
|
||||
|
||||
// the service should throw an error description 'Upload failed for File ID: file-123'
|
||||
await expect(
|
||||
helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 1000),
|
||||
).rejects.toMatchObject({ description: 'Upload failed for File ID: file-123' });
|
||||
});
|
||||
|
||||
it('should timeout if no matching event is received', async () => {
|
||||
const waitForSessionEventMock = GenericFunctions.waitForSessionEvent as jest.Mock;
|
||||
waitForSessionEventMock.mockRejectedValueOnce(new Error('Timeout reached'));
|
||||
|
||||
const mockExecuteFunction = createMockExecuteFunction({});
|
||||
|
||||
await expect(
|
||||
helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 100),
|
||||
).rejects.toThrow('Timeout reached');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushFileToSession', () => {
|
||||
it('should push file to session and wait', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
const mockFileId = 'file-123';
|
||||
const mockSessionId = 'session-123';
|
||||
apiRequestMock.mockResolvedValueOnce({});
|
||||
|
||||
// Mock waitForFileInSession
|
||||
const waitForFileInSessionMock = jest.fn().mockResolvedValueOnce({});
|
||||
|
||||
// Call the function
|
||||
await helpers.pushFileToSession.call(
|
||||
createMockExecuteFunction({}),
|
||||
mockFileId,
|
||||
mockSessionId,
|
||||
waitForFileInSessionMock,
|
||||
);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('POST', `/files/${mockFileId}/push`, {
|
||||
sessionIds: [mockSessionId],
|
||||
});
|
||||
|
||||
expect(waitForFileInSessionMock).toHaveBeenCalledWith(mockSessionId, mockFileId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerFileInput', () => {
|
||||
it('should trigger file input in window', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce({});
|
||||
const mockFileId = 'file-123';
|
||||
const mockWindowId = 'window-123';
|
||||
const mockSessionId = 'session-123';
|
||||
|
||||
await helpers.triggerFileInput.call(createMockExecuteFunction({}), {
|
||||
fileId: mockFileId,
|
||||
windowId: mockWindowId,
|
||||
sessionId: mockSessionId,
|
||||
elementDescription: 'test',
|
||||
includeHiddenElements: false,
|
||||
});
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
`/sessions/${mockSessionId}/windows/${mockWindowId}/file-input`,
|
||||
{
|
||||
fileId: mockFileId,
|
||||
elementDescription: 'test',
|
||||
includeHiddenElements: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFileBuffer', () => {
|
||||
it('should create buffer from URL', async () => {
|
||||
const mockUrl = 'https://example.com/file.pdf';
|
||||
const mockBuffer = [1, 2, 3];
|
||||
|
||||
// Mock http request
|
||||
const mockHttpRequest = jest.fn().mockResolvedValueOnce(mockBuffer);
|
||||
|
||||
// Create mock execute function with http request helper
|
||||
const mockExecuteFunction = createMockExecuteFunction({});
|
||||
mockExecuteFunction.helpers.httpRequest = mockHttpRequest;
|
||||
|
||||
const result = await helpers.createFileBuffer.call(mockExecuteFunction, 'url', mockUrl, 0);
|
||||
|
||||
expect(mockHttpRequest).toHaveBeenCalledWith({
|
||||
url: mockUrl,
|
||||
json: false,
|
||||
encoding: 'arraybuffer',
|
||||
});
|
||||
expect(result).toBe(mockBuffer);
|
||||
});
|
||||
|
||||
it('should create buffer from binary data', async () => {
|
||||
const mockBinaryPropertyName = 'data';
|
||||
const mockBuffer = [1, 2, 3];
|
||||
|
||||
// Mock getBinaryDataBuffer
|
||||
const mockGetBinaryDataBuffer = jest.fn().mockResolvedValue(mockBuffer);
|
||||
|
||||
// Create mock execute function with getBinaryDataBuffer helper
|
||||
const mockExecuteFunction = createMockExecuteFunction({});
|
||||
mockExecuteFunction.helpers.getBinaryDataBuffer = mockGetBinaryDataBuffer;
|
||||
|
||||
const result = await helpers.createFileBuffer.call(
|
||||
mockExecuteFunction,
|
||||
'binary',
|
||||
mockBinaryPropertyName,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(mockGetBinaryDataBuffer).toHaveBeenCalledWith(0, mockBinaryPropertyName);
|
||||
expect(result).toBe(mockBuffer);
|
||||
});
|
||||
|
||||
it('should throw error for unsupported source type', async () => {
|
||||
await expect(
|
||||
helpers.createFileBuffer.call(
|
||||
createMockExecuteFunction({}),
|
||||
'invalid-source',
|
||||
'test-value',
|
||||
0,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import get from 'lodash/get';
|
||||
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,
|
||||
options?: IGetNodeParameterOptions,
|
||||
) {
|
||||
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,182 @@
|
||||
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',
|
||||
clickType: 'click',
|
||||
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: {
|
||||
clickType: 'click',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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',
|
||||
},
|
||||
clickType: 'click',
|
||||
},
|
||||
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: {
|
||||
clickType: 'click',
|
||||
waitForNavigationConfig: {
|
||||
waitUntil: 'load',
|
||||
},
|
||||
},
|
||||
waitForNavigation: true,
|
||||
elementDescription: 'the login button',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should execute double click when 'clickType' is 'doubleClick'", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
clickType: 'doubleClick',
|
||||
};
|
||||
|
||||
await click.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/click',
|
||||
{
|
||||
elementDescription: 'the login button',
|
||||
configuration: {
|
||||
clickType: 'doubleClick',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should execute right click when 'clickType' is 'rightClick'", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
clickType: 'rightClick',
|
||||
};
|
||||
|
||||
await click.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/click',
|
||||
{
|
||||
elementDescription: 'the login button',
|
||||
configuration: {
|
||||
clickType: 'rightClick',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import * as fill from '../../../actions/interaction/fill.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'interaction',
|
||||
operation: 'fill',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
formData: 'Name: John Doe, Email: john@example.com',
|
||||
};
|
||||
|
||||
const mockAsyncResponse = {
|
||||
requestId: 'req-123',
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
const mockCompletedResponse = {
|
||||
status: 'completed',
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Form filled successfully',
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, fill form operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should execute fill operation successfully', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
|
||||
// Mock the initial async request
|
||||
apiRequestMock.mockResolvedValueOnce(mockAsyncResponse);
|
||||
|
||||
// Mock the status check to return completed after first pending
|
||||
apiRequestMock
|
||||
.mockResolvedValueOnce({ ...mockAsyncResponse })
|
||||
.mockResolvedValueOnce(mockCompletedResponse);
|
||||
|
||||
const result = await fill.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/async/sessions/test-session-123/windows/win-123/execute-automation',
|
||||
{
|
||||
automationId: 'auto',
|
||||
parameters: {
|
||||
customData: 'Name: John Doe, Email: john@example.com',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith('GET', '/requests/req-123/status');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: baseNodeParameters.windowId,
|
||||
status: 'completed',
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Form filled successfully',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when 'formData' parameter is empty", async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
formData: '',
|
||||
};
|
||||
|
||||
await expect(fill.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Form Data'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when operation times out after 2 sec', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
};
|
||||
const timeout = 2000;
|
||||
|
||||
// Mock the initial async request
|
||||
apiRequestMock.mockResolvedValueOnce(mockAsyncResponse);
|
||||
|
||||
// Return pending on all requests
|
||||
apiRequestMock.mockResolvedValue({ ...mockAsyncResponse });
|
||||
|
||||
// should throw NodeApiError
|
||||
await expect(
|
||||
fill.execute.call(createMockExecuteFunction(nodeParameters), 0, timeout),
|
||||
).rejects.toThrow('The service was not able to process your request');
|
||||
});
|
||||
|
||||
it('should handle error status in response', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
const errorResponse = {
|
||||
status: 'error',
|
||||
error: {
|
||||
message: 'Failed to fill form',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the initial async request
|
||||
apiRequestMock.mockResolvedValueOnce(mockAsyncResponse);
|
||||
|
||||
// Mock the status check to return error
|
||||
apiRequestMock
|
||||
.mockResolvedValueOnce({ ...mockAsyncResponse })
|
||||
.mockResolvedValueOnce(errorResponse);
|
||||
|
||||
const result = await fill.execute.call(createMockExecuteFunction(baseNodeParameters), 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseNodeParameters.sessionId,
|
||||
windowId: baseNodeParameters.windowId,
|
||||
status: 'error',
|
||||
error: {
|
||||
message: 'Failed to fill form',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,170 @@
|
||||
import * as scroll from '../../../actions/interaction/scroll.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'interaction',
|
||||
operation: 'scroll',
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
additionalFields: {},
|
||||
};
|
||||
|
||||
const baseAutomaticNodeParameters = {
|
||||
...baseNodeParameters,
|
||||
scrollingMode: 'automatic',
|
||||
scrollToElement: 'the bottom of the page',
|
||||
scrollWithin: '',
|
||||
};
|
||||
|
||||
const baseManualNodeParameters = {
|
||||
...baseNodeParameters,
|
||||
scrollingMode: 'manual',
|
||||
scrollToEdge: {
|
||||
edgeValues: {
|
||||
yAxis: 'bottom',
|
||||
xAxis: '',
|
||||
},
|
||||
},
|
||||
scrollBy: {
|
||||
scrollValues: {
|
||||
yAxis: '200px',
|
||||
xAxis: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
message: 'Scrolled successfully',
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, scroll operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should execute automatic scroll operation successfully', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await scroll.execute.call(
|
||||
createMockExecuteFunction(baseAutomaticNodeParameters),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/scroll',
|
||||
{
|
||||
scrollToElement: 'the bottom of the page',
|
||||
configuration: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseAutomaticNodeParameters.sessionId,
|
||||
windowId: baseAutomaticNodeParameters.windowId,
|
||||
success: true,
|
||||
message: 'Scrolled successfully',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should execute manual scroll operation successfully', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await scroll.execute.call(
|
||||
createMockExecuteFunction(baseManualNodeParameters),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/sessions/test-session-123/windows/win-123/scroll',
|
||||
{
|
||||
configuration: {},
|
||||
scrollToEdge: {
|
||||
yAxis: 'bottom',
|
||||
xAxis: '',
|
||||
},
|
||||
scrollBy: {
|
||||
yAxis: '200px',
|
||||
xAxis: '',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: baseManualNodeParameters.sessionId,
|
||||
windowId: baseManualNodeParameters.windowId,
|
||||
success: true,
|
||||
message: 'Scrolled successfully',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when scrollingMode is 'automatic' and 'scrollToElement' parameter is empty", async () => {
|
||||
const nodeParameters = {
|
||||
...baseAutomaticNodeParameters,
|
||||
scrollToElement: '',
|
||||
};
|
||||
|
||||
await expect(scroll.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Element Description'),
|
||||
);
|
||||
});
|
||||
|
||||
it("should validate scroll amount formats when scrollingMode is 'manual'", async () => {
|
||||
const invalidNodeParameters = {
|
||||
...baseManualNodeParameters,
|
||||
scrollBy: {
|
||||
scrollValues: {
|
||||
yAxis: 'one hundred pixels',
|
||||
xAxis: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
scroll.execute.call(createMockExecuteFunction(invalidNodeParameters), 0),
|
||||
).rejects.toThrow(ERROR_MESSAGES.SCROLL_BY_AMOUNT_INVALID);
|
||||
});
|
||||
|
||||
it('should throw an error when the API returns an error response', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
const errorResponse = {
|
||||
errors: [
|
||||
{
|
||||
message: 'Failed to scroll',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
apiRequestMock.mockResolvedValueOnce(errorResponse);
|
||||
|
||||
await expect(
|
||||
scroll.execute.call(createMockExecuteFunction(baseAutomaticNodeParameters), 0),
|
||||
).rejects.toThrow('Failed to scroll');
|
||||
});
|
||||
});
|
||||
@@ -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,310 @@
|
||||
import * as create from '../../../actions/session/create.operation';
|
||||
import { ERROR_MESSAGES, SESSION_STATUS } from '../../../constants';
|
||||
import * as transport from '../../../transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const mockCreatedSession = {
|
||||
data: { id: 'test-session-123', status: SESSION_STATUS.RUNNING },
|
||||
};
|
||||
|
||||
const baseNodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'create',
|
||||
profileName: 'test-profile',
|
||||
record: false,
|
||||
timeoutMinutes: 10,
|
||||
saveProfileOnTermination: false,
|
||||
};
|
||||
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {
|
||||
...mockCreatedSession,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, session create operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../transport');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
/**
|
||||
* Minimal parameters
|
||||
*/
|
||||
it('should create a session with minimal parameters', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
proxy: 'none',
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', {
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
solveCaptcha: false,
|
||||
timeoutMinutes: 10,
|
||||
record: false,
|
||||
proxy: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
/**
|
||||
* Profiles
|
||||
*/
|
||||
it('should create a session with save profile enabled', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
saveProfileOnTermination: true,
|
||||
proxy: 'none',
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenNthCalledWith(1, 'POST', '/sessions', {
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
solveCaptcha: false,
|
||||
timeoutMinutes: 10,
|
||||
record: false,
|
||||
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',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
/**
|
||||
* Proxy
|
||||
*/
|
||||
it('should create a session with integrated proxy and empty config', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
proxy: 'integrated',
|
||||
proxyConfig: {}, // simulate integrated proxy with empty config
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', {
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
solveCaptcha: false,
|
||||
timeoutMinutes: 10,
|
||||
record: false,
|
||||
proxy: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a session with integrated proxy and proxy configuration', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
proxy: 'integrated',
|
||||
proxyConfig: { country: 'US', sticky: true },
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', {
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
solveCaptcha: false,
|
||||
timeoutMinutes: 10,
|
||||
record: false,
|
||||
proxy: { country: 'US', sticky: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a session with proxy URL', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
proxy: 'proxyUrl',
|
||||
proxyUrl: 'http://proxy.example.com:8080',
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', {
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
solveCaptcha: false,
|
||||
timeoutMinutes: 10,
|
||||
record: false,
|
||||
proxy: 'http://proxy.example.com:8080',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when custom proxy URL is empty', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
proxy: 'proxyUrl',
|
||||
proxyUrl: '',
|
||||
};
|
||||
|
||||
await expect(create.execute.call(createMockExecuteFunction(nodeParameters), 0)).rejects.toThrow(
|
||||
ERROR_MESSAGES.PROXY_URL_REQUIRED,
|
||||
);
|
||||
});
|
||||
/**
|
||||
* Auto solve captcha
|
||||
*/
|
||||
it('should create a session with auto solve captcha enabled', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
additionalFields: {
|
||||
solveCaptcha: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', {
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
solveCaptcha: true,
|
||||
timeoutMinutes: 10,
|
||||
record: false,
|
||||
proxy: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
/**
|
||||
* Chrome extensions
|
||||
*/
|
||||
it('should create a session with chrome extensions enabled', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
additionalFields: {
|
||||
extensionIds: 'extId1, extId2',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', {
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
solveCaptcha: false,
|
||||
timeoutMinutes: 10,
|
||||
record: false,
|
||||
proxy: false,
|
||||
extensionIds: ['extId1', 'extId2'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
/**
|
||||
* Session recording
|
||||
*/
|
||||
it('should create a session with recording enabled', async () => {
|
||||
const nodeParameters = {
|
||||
...baseNodeParameters,
|
||||
record: true,
|
||||
proxy: 'none',
|
||||
};
|
||||
|
||||
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', {
|
||||
configuration: {
|
||||
profileName: 'test-profile',
|
||||
solveCaptcha: false,
|
||||
timeoutMinutes: 10,
|
||||
record: true,
|
||||
proxy: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,85 @@
|
||||
import * as waitForDownload from '../../../actions/session/waitForDownload.operation';
|
||||
import { ERROR_MESSAGES } from '../../../constants';
|
||||
import * as GenericFunctions from '../../../GenericFunctions';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
jest.mock('../../../GenericFunctions', () => {
|
||||
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
|
||||
return {
|
||||
...originalModule,
|
||||
waitForSessionEvent: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test Airtop, session waitForDownload operation', () => {
|
||||
afterAll(() => {
|
||||
jest.unmock('../../../GenericFunctions');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should wait for download successfully', async () => {
|
||||
const mockEvent = {
|
||||
event: 'file_status',
|
||||
status: 'available',
|
||||
fileId: 'test-file-123',
|
||||
downloadUrl: 'https://example.com/download/test-file-123',
|
||||
};
|
||||
|
||||
(GenericFunctions.waitForSessionEvent as jest.Mock).mockResolvedValue(mockEvent);
|
||||
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'waitForDownload',
|
||||
sessionId: 'test-session-123',
|
||||
timeout: 1,
|
||||
};
|
||||
|
||||
const result = await waitForDownload.execute.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
|
||||
expect(GenericFunctions.waitForSessionEvent).toHaveBeenCalledTimes(1);
|
||||
expect(GenericFunctions.waitForSessionEvent).toHaveBeenCalledWith(
|
||||
'test-session-123',
|
||||
expect.any(Function),
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: {
|
||||
fileId: 'test-file-123',
|
||||
downloadUrl: 'https://example.com/download/test-file-123',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when sessionId is empty', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'session',
|
||||
operation: 'waitForDownload',
|
||||
sessionId: '',
|
||||
};
|
||||
|
||||
await expect(
|
||||
waitForDownload.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: 'waitForDownload',
|
||||
sessionId: ' ',
|
||||
};
|
||||
|
||||
await expect(
|
||||
waitForDownload.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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,135 @@
|
||||
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 expectedJsonResult = {
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
windowId: 'win-123',
|
||||
image: 'base64-encoded-image-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 in base64 format', 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([{ ...expectedJsonResult }]);
|
||||
});
|
||||
|
||||
it('should take screenshot in binary format', async () => {
|
||||
const result = await takeScreenshot.execute.call(
|
||||
createMockExecuteFunction({
|
||||
...baseNodeParameters,
|
||||
outputImageAsBinary: true,
|
||||
}),
|
||||
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: { ...expectedJsonResult.json, image: '' },
|
||||
...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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
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({
|
||||
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({
|
||||
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',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { ERROR_MESSAGES, SESSION_STATUS } from '../constants';
|
||||
import {
|
||||
createSession,
|
||||
createSessionAndWindow,
|
||||
validateProfileName,
|
||||
validateTimeoutMinutes,
|
||||
validateSaveProfileOnTermination,
|
||||
validateSessionAndWindowId,
|
||||
validateAirtopApiResponse,
|
||||
validateSessionId,
|
||||
validateUrl,
|
||||
validateProxy,
|
||||
validateRequiredStringField,
|
||||
shouldCreateNewSession,
|
||||
convertScreenshotToBinary,
|
||||
} from '../GenericFunctions';
|
||||
import type * as transport from '../transport';
|
||||
import { createMockExecuteFunction } from './node/helpers';
|
||||
|
||||
const mockCreatedSession = {
|
||||
data: { id: 'new-session-123', status: SESSION_STATUS.RUNNING },
|
||||
};
|
||||
|
||||
jest.mock('../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async (method: string, endpoint: string, params: { fail?: boolean }) => {
|
||||
// return failed request
|
||||
if (endpoint.endsWith('/sessions') && params.fail) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// create session
|
||||
if (method === 'POST' && endpoint.endsWith('/sessions')) {
|
||||
return { ...mockCreatedSession };
|
||||
}
|
||||
|
||||
// get session status - general case
|
||||
if (method === 'GET' && endpoint.includes('/sessions')) {
|
||||
return { ...mockCreatedSession };
|
||||
}
|
||||
|
||||
// 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('validateProxy', () => {
|
||||
it('should validate intergated proxy', () => {
|
||||
const nodeParameters = {
|
||||
proxy: 'integrated',
|
||||
};
|
||||
|
||||
const result = validateProxy.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toEqual({ proxy: true });
|
||||
});
|
||||
|
||||
it('should validate proxyUrl', () => {
|
||||
const nodeParameters = {
|
||||
proxy: 'proxyUrl',
|
||||
proxyUrl: 'http://example.com',
|
||||
};
|
||||
|
||||
const result = validateProxy.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toEqual({ proxy: 'http://example.com' });
|
||||
});
|
||||
|
||||
it('should throw error for empty proxyUrl', () => {
|
||||
const nodeParameters = {
|
||||
proxy: 'proxyUrl',
|
||||
proxyUrl: '',
|
||||
};
|
||||
|
||||
expect(() => validateProxy.call(createMockExecuteFunction(nodeParameters), 0)).toThrow(
|
||||
ERROR_MESSAGES.REQUIRED_PARAMETER.replace('{{field}}', 'Proxy URL'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate integrated proxy with config', () => {
|
||||
const nodeParameters = {
|
||||
proxy: 'integrated',
|
||||
proxyConfig: { country: 'US', sticky: true },
|
||||
};
|
||||
|
||||
const result = validateProxy.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toEqual({ proxy: { country: 'US', sticky: true } });
|
||||
});
|
||||
|
||||
it('should validate none proxy', () => {
|
||||
const nodeParameters = {
|
||||
proxy: 'none',
|
||||
};
|
||||
|
||||
const result = validateProxy.call(createMockExecuteFunction(nodeParameters), 0);
|
||||
expect(result).toEqual({ proxy: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAirtopApiResponse', () => {
|
||||
const mockNode = {
|
||||
id: '1',
|
||||
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('createSession', () => {
|
||||
it('should create a session and return the session ID', async () => {
|
||||
const result = await createSession.call(createMockExecuteFunction({}), {});
|
||||
expect(result).toEqual({
|
||||
sessionId: 'new-session-123',
|
||||
data: { ...mockCreatedSession },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if no session ID is returned', async () => {
|
||||
await expect(
|
||||
createSession.call(createMockExecuteFunction({}), { fail: true }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSessionAndWindow', () => {
|
||||
it("should create a new session and window when sessionMode is 'new'", async () => {
|
||||
const nodeParameters = {
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
IHttpRequestOptions,
|
||||
ILoadOptionsFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { IAirtopResponse } from './types';
|
||||
import { BASE_URL, N8N_VERSION } from '../constants';
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-airtop-sdk-environment': 'n8n',
|
||||
'x-airtop-sdk-version': N8N_VERSION,
|
||||
};
|
||||
|
||||
export async function apiRequest<T extends IAirtopResponse = IAirtopResponse>(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
query: IDataObject = {},
|
||||
): Promise<T> {
|
||||
const options: IHttpRequestOptions = {
|
||||
headers: defaultHeaders,
|
||||
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<
|
||||
IExecuteFunctions | ILoadOptionsFunctions,
|
||||
[string, IHttpRequestOptions],
|
||||
Promise<T>
|
||||
>(this, 'airtopApi', options);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
export interface IAirtopSessionResponse extends IDataObject {
|
||||
data: {
|
||||
id: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IAirtopResponse extends IDataObject {
|
||||
sessionId?: string;
|
||||
windowId?: string;
|
||||
data?: IDataObject & {
|
||||
windowId?: string;
|
||||
modelResponse?: string;
|
||||
files?: IDataObject[];
|
||||
};
|
||||
meta?: IDataObject & {
|
||||
status?: string;
|
||||
screenshots?: Array<{ dataUrl: string }>;
|
||||
};
|
||||
errors?: IDataObject[];
|
||||
warnings?: IDataObject[];
|
||||
output?: IDataObject;
|
||||
}
|
||||
|
||||
export interface IAirtopResponseWithFiles extends IAirtopResponse {
|
||||
data: {
|
||||
files: IDataObject[];
|
||||
fileName?: string;
|
||||
status?: string;
|
||||
downloadUrl?: string;
|
||||
pagination: {
|
||||
hasMore: boolean;
|
||||
};
|
||||
sessionIds?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IAirtopInteractionRequest extends IDataObject {
|
||||
text?: string;
|
||||
waitForNavigation?: boolean;
|
||||
elementDescription?: string;
|
||||
pressEnterKey?: boolean;
|
||||
// scroll parameters
|
||||
scrollToElement?: string;
|
||||
scrollWithin?: string;
|
||||
scrollToEdge?: {
|
||||
xAxis?: string;
|
||||
yAxis?: string;
|
||||
};
|
||||
scrollBy?: {
|
||||
xAxis?: string;
|
||||
yAxis?: string;
|
||||
};
|
||||
// configuration
|
||||
configuration: {
|
||||
visualAnalysis?: {
|
||||
scope: string;
|
||||
};
|
||||
waitForNavigationConfig?: {
|
||||
waitUntil: string;
|
||||
};
|
||||
clickType?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IAirtopFileInputRequest extends IDataObject {
|
||||
fileId: string;
|
||||
windowId: string;
|
||||
sessionId: string;
|
||||
elementDescription?: string;
|
||||
includeHiddenElements?: boolean;
|
||||
}
|
||||
|
||||
export interface IAirtopNodeExecutionData extends INodeExecutionData {
|
||||
json: IAirtopResponse;
|
||||
}
|
||||
|
||||
export interface IAirtopServerEvent {
|
||||
event: string;
|
||||
eventData: {
|
||||
error?: string;
|
||||
};
|
||||
fileId?: string;
|
||||
status?: string;
|
||||
downloadUrl?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user