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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user