mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(Airtop Node): Implement windows list API and other improvements (#16748)
Co-authored-by: Eugene <eugene@n8n.io> Co-authored-by: Daria <daria.staferova@n8n.io> Co-authored-by: Alex Grozav <alex@grozav.com> Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com> Co-authored-by: Charlie Kolb <charlie@n8n.io> Co-authored-by: Elias Meire <elias@meire.dev> Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: shortstacked <declan@n8n.io> Co-authored-by: oleg <me@olegivaniv.com> Co-authored-by: Csaba Tuncsik <csaba@n8n.io> Co-authored-by: Jaakko Husso <jaakko@n8n.io> Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com> Co-authored-by: Suguru Inoue <suguru@n8n.io> Co-authored-by: Milorad FIlipović <milorad@n8n.io> Co-authored-by: Danny Martini <danny@n8n.io> Co-authored-by: Iván Ovejero <ivov.src@gmail.com> Co-authored-by: RomanDavydchuk <roman.davydchuk@n8n.io> Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Ricardo Espinoza <ricardo@n8n.io> Co-authored-by: Dana <152518854+dana-gill@users.noreply.github.com> Co-authored-by: Michael Kret <michael.k@radency.com> Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
This commit is contained in:
@@ -1,18 +1,30 @@
|
|||||||
import { NodeApiError, type IExecuteFunctions, type INode, type IDataObject } from 'n8n-workflow';
|
import {
|
||||||
|
NodeApiError,
|
||||||
|
type IExecuteFunctions,
|
||||||
|
type INode,
|
||||||
|
type IDataObject,
|
||||||
|
jsonParse,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import { NodeOperationError } from 'n8n-workflow';
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
import type Stream from 'node:stream';
|
||||||
|
|
||||||
import { SESSION_MODE } from './actions/common/fields';
|
import { SESSION_MODE } from './actions/common/fields';
|
||||||
import type { TScrollingMode } from './constants';
|
import { BASE_URL, type TScrollingMode } from './constants';
|
||||||
import {
|
import {
|
||||||
ERROR_MESSAGES,
|
ERROR_MESSAGES,
|
||||||
DEFAULT_TIMEOUT_MINUTES,
|
DEFAULT_TIMEOUT_MINUTES,
|
||||||
|
DEFAULT_DOWNLOAD_TIMEOUT_SECONDS,
|
||||||
MIN_TIMEOUT_MINUTES,
|
MIN_TIMEOUT_MINUTES,
|
||||||
MAX_TIMEOUT_MINUTES,
|
MAX_TIMEOUT_MINUTES,
|
||||||
SESSION_STATUS,
|
SESSION_STATUS,
|
||||||
OPERATION_TIMEOUT,
|
OPERATION_TIMEOUT,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { apiRequest } from './transport';
|
import { apiRequest } from './transport';
|
||||||
import type { IAirtopResponse, IAirtopSessionResponse } from './transport/types';
|
import type {
|
||||||
|
IAirtopResponse,
|
||||||
|
IAirtopServerEvent,
|
||||||
|
IAirtopSessionResponse,
|
||||||
|
} from './transport/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a required string field
|
* Validate a required string field
|
||||||
@@ -364,7 +376,7 @@ export async function createSession(
|
|||||||
this: IExecuteFunctions,
|
this: IExecuteFunctions,
|
||||||
parameters: IDataObject,
|
parameters: IDataObject,
|
||||||
timeout = OPERATION_TIMEOUT,
|
timeout = OPERATION_TIMEOUT,
|
||||||
): Promise<{ sessionId: string }> {
|
): Promise<{ sessionId: string; data: IAirtopSessionResponse }> {
|
||||||
// Request session creation
|
// Request session creation
|
||||||
const response = (await apiRequest.call(
|
const response = (await apiRequest.call(
|
||||||
this,
|
this,
|
||||||
@@ -401,7 +413,12 @@ export async function createSession(
|
|||||||
sessionStatus = sessionStatusResponse.data.status;
|
sessionStatus = sessionStatusResponse.data.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sessionId };
|
return {
|
||||||
|
sessionId,
|
||||||
|
data: {
|
||||||
|
...response,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -446,3 +463,77 @@ export async function createSessionAndWindow(
|
|||||||
this.logger.info(`[${node.name}] Window successfully created.`);
|
this.logger.info(`[${node.name}] Window successfully created.`);
|
||||||
return { sessionId, windowId };
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -163,3 +163,11 @@ export function getSessionModeFields(resource: string, operations: string[]): IN
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const includeHiddenElementsField: INodeProperties = {
|
||||||
|
displayName: 'Include Hidden Elements',
|
||||||
|
name: 'includeHiddenElements',
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
description: 'Whether to include hidden elements in the interaction',
|
||||||
|
};
|
||||||
|
|||||||
@@ -25,22 +25,33 @@ export async function executeRequestWithSessionManagement(
|
|||||||
body: IDataObject;
|
body: IDataObject;
|
||||||
},
|
},
|
||||||
): Promise<IAirtopResponse> {
|
): Promise<IAirtopResponse> {
|
||||||
const { sessionId, windowId } = shouldCreateNewSession.call(this, index)
|
let airtopSessionId = '';
|
||||||
? await createSessionAndWindow.call(this, index)
|
try {
|
||||||
: validateSessionAndWindowId.call(this, index);
|
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 shouldTerminateSession = this.getNodeParameter('autoTerminateSession', index, false);
|
||||||
|
|
||||||
const endpoint = request.path.replace('{sessionId}', sessionId).replace('{windowId}', windowId);
|
const endpoint = request.path.replace('{sessionId}', sessionId).replace('{windowId}', windowId);
|
||||||
const response = await apiRequest.call(this, request.method, endpoint, request.body);
|
const response = await apiRequest.call(this, request.method, endpoint, request.body);
|
||||||
|
|
||||||
validateAirtopApiResponse(this.getNode(), response);
|
validateAirtopApiResponse(this.getNode(), response);
|
||||||
|
|
||||||
if (shouldTerminateSession) {
|
if (shouldTerminateSession) {
|
||||||
await apiRequest.call(this, 'DELETE', `/sessions/${sessionId}`);
|
await apiRequest.call(this, 'DELETE', `/sessions/${sessionId}`);
|
||||||
this.logger.info(`[${this.getNode().name}] Session terminated.`);
|
this.logger.info(`[${this.getNode().name}] Session terminated.`);
|
||||||
return response;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sessionId, windowId, ...response };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import pick from 'lodash/pick';
|
||||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||||
import { jsonParse, NodeApiError } from 'n8n-workflow';
|
import { NodeApiError } from 'n8n-workflow';
|
||||||
import type { Stream } from 'stream';
|
|
||||||
|
|
||||||
import { BASE_URL, ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants';
|
import { BASE_URL, ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants';
|
||||||
import { apiRequest } from '../../transport';
|
import { apiRequest } from '../../transport';
|
||||||
import type { IAirtopResponseWithFiles, IAirtopServerEvent } from '../../transport/types';
|
import type { IAirtopResponseWithFiles, IAirtopFileInputRequest } from '../../transport/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches all files from the Airtop API using pagination
|
* Fetches all files from the Airtop API using pagination
|
||||||
@@ -132,27 +132,11 @@ export async function createAndUploadFile(
|
|||||||
return await pollingFunction.call(this, fileId as string);
|
return await pollingFunction.call(this, fileId as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEvent(eventText: string): IAirtopServerEvent | null {
|
|
||||||
const dataLine = eventText.split('\n').find((line) => line.startsWith('data:'));
|
|
||||||
if (!dataLine) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const jsonStr = dataLine.replace('data: ', '').trim();
|
|
||||||
return jsonParse<IAirtopServerEvent>(jsonStr, {
|
|
||||||
errorMessage: 'Failed to parse server event',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFileAvailable(event: IAirtopServerEvent, fileId: string): boolean {
|
|
||||||
return (
|
|
||||||
event.event === 'file_upload_status' && event.fileId === fileId && event.status === 'available'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Waits for a file to be ready in a session by monitoring session events
|
* 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 this - The execution context providing access to n8n functionality
|
||||||
* @param sessionId - ID of the session to monitor for file events
|
* @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)
|
* @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
|
* @returns Promise that resolves when a file in the session becomes available
|
||||||
* @throws NodeApiError if the timeout is reached before a file becomes available
|
* @throws NodeApiError if the timeout is reached before a file becomes available
|
||||||
@@ -163,61 +147,25 @@ export async function waitForFileInSession(
|
|||||||
fileId: string,
|
fileId: string,
|
||||||
timeout = OPERATION_TIMEOUT,
|
timeout = OPERATION_TIMEOUT,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const url = `${BASE_URL}/sessions/${sessionId}/events?all=true`;
|
const url = `${BASE_URL}/files/${fileId}`;
|
||||||
|
const isFileInSession = async (): Promise<boolean> => {
|
||||||
|
const fileInfo = (await apiRequest.call(this, 'GET', url)) as IAirtopResponseWithFiles;
|
||||||
|
return Boolean(fileInfo.data?.sessionIds?.includes(sessionId));
|
||||||
|
};
|
||||||
|
|
||||||
const fileReadyPromise = new Promise<void>(async (resolve, reject) => {
|
const startTime = Date.now();
|
||||||
const stream = (await this.helpers.httpRequestWithAuthentication.call(this, 'airtopApi', {
|
while (!(await isFileInSession())) {
|
||||||
method: 'GET',
|
const elapsedTime = Date.now() - startTime;
|
||||||
url,
|
// throw error if timeout is reached
|
||||||
encoding: 'stream',
|
if (elapsedTime >= timeout) {
|
||||||
})) as Stream;
|
throw new NodeApiError(this.getNode(), {
|
||||||
|
message: ERROR_MESSAGES.TIMEOUT_REACHED,
|
||||||
const close = () => {
|
|
||||||
resolve();
|
|
||||||
stream.removeAllListeners();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onError = (errorMessage: string) => {
|
|
||||||
const error = new NodeApiError(this.getNode(), {
|
|
||||||
message: errorMessage,
|
|
||||||
description: 'Failed to upload file',
|
|
||||||
code: 500,
|
code: 500,
|
||||||
});
|
});
|
||||||
reject(error);
|
}
|
||||||
stream.removeAllListeners();
|
// wait 1 second before checking again
|
||||||
};
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
stream.on('data', (data: Uint8Array) => {
|
|
||||||
const event = parseEvent(data.toString());
|
|
||||||
if (!event) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// handle error
|
|
||||||
if (event?.eventData?.error) {
|
|
||||||
onError(event.eventData.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// handle file available
|
|
||||||
if (isFileAvailable(event, fileId)) {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<void>((_resolve, reject) => {
|
|
||||||
setTimeout(
|
|
||||||
() =>
|
|
||||||
reject(
|
|
||||||
new NodeApiError(this.getNode(), {
|
|
||||||
message: ERROR_MESSAGES.TIMEOUT_REACHED,
|
|
||||||
code: 500,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
timeout,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.race([fileReadyPromise, timeoutPromise]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -249,15 +197,14 @@ export async function pushFileToSession(
|
|||||||
*/
|
*/
|
||||||
export async function triggerFileInput(
|
export async function triggerFileInput(
|
||||||
this: IExecuteFunctions,
|
this: IExecuteFunctions,
|
||||||
fileId: string,
|
request: IAirtopFileInputRequest,
|
||||||
windowId: string,
|
|
||||||
sessionId: string,
|
|
||||||
elementDescription = '',
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await apiRequest.call(this, 'POST', `/sessions/${sessionId}/windows/${windowId}/file-input`, {
|
await apiRequest.call(
|
||||||
fileId,
|
this,
|
||||||
...(elementDescription ? { elementDescription } : {}),
|
'POST',
|
||||||
});
|
`/sessions/${request.sessionId}/windows/${request.windowId}/file-input`,
|
||||||
|
pick(request, ['fileId', 'elementDescription', 'includeHiddenElements']),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n
|
|||||||
import { NodeOperationError } from 'n8n-workflow';
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
import { pushFileToSession, triggerFileInput } from './helpers';
|
import { pushFileToSession, triggerFileInput } from './helpers';
|
||||||
import { sessionIdField, windowIdField, elementDescriptionField } from '../common/fields';
|
import {
|
||||||
|
sessionIdField,
|
||||||
|
windowIdField,
|
||||||
|
elementDescriptionField,
|
||||||
|
includeHiddenElementsField,
|
||||||
|
} from '../common/fields';
|
||||||
|
|
||||||
const displayOptions = {
|
const displayOptions = {
|
||||||
show: {
|
show: {
|
||||||
@@ -37,6 +42,10 @@ export const description: INodeProperties[] = [
|
|||||||
placeholder: 'e.g. the file upload selection box',
|
placeholder: 'e.g. the file upload selection box',
|
||||||
displayOptions,
|
displayOptions,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
...includeHiddenElementsField,
|
||||||
|
displayOptions,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function execute(
|
export async function execute(
|
||||||
@@ -47,10 +56,21 @@ export async function execute(
|
|||||||
const sessionId = this.getNodeParameter('sessionId', index, '') as string;
|
const sessionId = this.getNodeParameter('sessionId', index, '') as string;
|
||||||
const windowId = this.getNodeParameter('windowId', index, '') as string;
|
const windowId = this.getNodeParameter('windowId', index, '') as string;
|
||||||
const elementDescription = this.getNodeParameter('elementDescription', index, '') as string;
|
const elementDescription = this.getNodeParameter('elementDescription', index, '') as string;
|
||||||
|
const includeHiddenElements = this.getNodeParameter(
|
||||||
|
'includeHiddenElements',
|
||||||
|
index,
|
||||||
|
false,
|
||||||
|
) as boolean;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pushFileToSession.call(this, fileId, sessionId);
|
await pushFileToSession.call(this, fileId, sessionId);
|
||||||
await triggerFileInput.call(this, fileId, windowId, sessionId, elementDescription);
|
await triggerFileInput.call(this, {
|
||||||
|
fileId,
|
||||||
|
windowId,
|
||||||
|
sessionId,
|
||||||
|
elementDescription,
|
||||||
|
includeHiddenElements,
|
||||||
|
});
|
||||||
|
|
||||||
return this.helpers.returnJsonArray({
|
return this.helpers.returnJsonArray({
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import {
|
|||||||
createFileBuffer,
|
createFileBuffer,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
import { validateRequiredStringField } from '../../GenericFunctions';
|
import { validateRequiredStringField } from '../../GenericFunctions';
|
||||||
import { sessionIdField, windowIdField, elementDescriptionField } from '../common/fields';
|
import {
|
||||||
|
sessionIdField,
|
||||||
|
windowIdField,
|
||||||
|
elementDescriptionField,
|
||||||
|
includeHiddenElementsField,
|
||||||
|
} from '../common/fields';
|
||||||
|
|
||||||
const displayOptions = {
|
const displayOptions = {
|
||||||
show: {
|
show: {
|
||||||
@@ -130,6 +135,15 @@ export const description: INodeProperties[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
...includeHiddenElementsField,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
triggerFileInputParameter: [true],
|
||||||
|
...displayOptions.show,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function execute(
|
export async function execute(
|
||||||
@@ -149,7 +163,11 @@ export async function execute(
|
|||||||
true,
|
true,
|
||||||
) as boolean;
|
) as boolean;
|
||||||
const elementDescription = this.getNodeParameter('elementDescription', index, '') as string;
|
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
|
// Get the file content based on source type
|
||||||
const fileValue = source === 'url' ? url : binaryPropertyName;
|
const fileValue = source === 'url' ? url : binaryPropertyName;
|
||||||
|
|
||||||
@@ -160,7 +178,13 @@ export async function execute(
|
|||||||
await pushFileToSession.call(this, fileId, sessionId);
|
await pushFileToSession.call(this, fileId, sessionId);
|
||||||
|
|
||||||
if (triggerFileInputParameter) {
|
if (triggerFileInputParameter) {
|
||||||
await triggerFileInput.call(this, fileId, windowId, sessionId, elementDescription);
|
await triggerFileInput.call(this, {
|
||||||
|
fileId,
|
||||||
|
windowId,
|
||||||
|
sessionId,
|
||||||
|
elementDescription,
|
||||||
|
includeHiddenElements,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.helpers.returnJsonArray({
|
return this.helpers.returnJsonArray({
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const description: INodeProperties[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Scroll To Page Edges',
|
displayName: 'Scroll To Edge',
|
||||||
name: 'scrollToEdge',
|
name: 'scrollToEdge',
|
||||||
type: 'fixedCollection',
|
type: 'fixedCollection',
|
||||||
default: {},
|
default: {},
|
||||||
@@ -172,7 +172,6 @@ export const description: INodeProperties[] = [
|
|||||||
show: {
|
show: {
|
||||||
resource: ['interaction'],
|
resource: ['interaction'],
|
||||||
operation: ['scroll'],
|
operation: ['scroll'],
|
||||||
scrollingMode: ['automatic'],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -206,7 +205,8 @@ export async function execute(
|
|||||||
...(!isAutomatic ? { scrollBy } : {}),
|
...(!isAutomatic ? { scrollBy } : {}),
|
||||||
// when scrollingMode is 'Automatic'
|
// when scrollingMode is 'Automatic'
|
||||||
...(isAutomatic ? { scrollToElement } : {}),
|
...(isAutomatic ? { scrollToElement } : {}),
|
||||||
...(isAutomatic ? { scrollWithin } : {}),
|
// when scrollWithin is defined
|
||||||
|
...(scrollWithin ? { scrollWithin } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const fullRequest = constructInteractionRequest.call(this, index, request);
|
const fullRequest = constructInteractionRequest.call(this, index, request);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get cleaner output when called as tool
|
// Get cleaner output when called as tool
|
||||||
if (isCalledAsTool && !['session', 'window'].includes(resource)) {
|
if (isCalledAsTool) {
|
||||||
responseData = cleanOutputForToolUse(responseData);
|
responseData = cleanOutputForToolUse(responseData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import type { INodeProperties } from 'n8n-workflow';
|
|||||||
import * as create from './create.operation';
|
import * as create from './create.operation';
|
||||||
import * as save from './save.operation';
|
import * as save from './save.operation';
|
||||||
import * as terminate from './terminate.operation';
|
import * as terminate from './terminate.operation';
|
||||||
|
import * as waitForDownload from './waitForDownload.operation';
|
||||||
|
|
||||||
export { create, save, terminate };
|
export { create, save, terminate, waitForDownload };
|
||||||
|
|
||||||
export const description: INodeProperties[] = [
|
export const description: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
@@ -37,10 +38,17 @@ export const description: INodeProperties[] = [
|
|||||||
description: 'Terminate a session',
|
description: 'Terminate a session',
|
||||||
action: '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',
|
default: 'create',
|
||||||
},
|
},
|
||||||
...create.description,
|
...create.description,
|
||||||
...save.description,
|
...save.description,
|
||||||
...terminate.description,
|
...terminate.description,
|
||||||
|
...waitForDownload.description,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export async function execute(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { sessionId } = await createSession.call(this, body);
|
const { sessionId, data } = await createSession.call(this, body);
|
||||||
|
|
||||||
if (saveProfileOnTermination) {
|
if (saveProfileOnTermination) {
|
||||||
await apiRequest.call(
|
await apiRequest.call(
|
||||||
@@ -189,5 +189,5 @@ export async function execute(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.helpers.returnJsonArray({ sessionId } as IDataObject);
|
return this.helpers.returnJsonArray({ sessionId, ...data });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ import type { INodeProperties } from 'n8n-workflow';
|
|||||||
|
|
||||||
import * as close from './close.operation';
|
import * as close from './close.operation';
|
||||||
import * as create from './create.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 load from './load.operation';
|
||||||
import * as takeScreenshot from './takeScreenshot.operation';
|
import * as takeScreenshot from './takeScreenshot.operation';
|
||||||
import { sessionIdField, windowIdField } from '../common/fields';
|
import { sessionIdField, windowIdField } from '../common/fields';
|
||||||
|
|
||||||
export { create, close, takeScreenshot, load };
|
export { create, close, takeScreenshot, load, list, getLiveView };
|
||||||
|
|
||||||
export const description: INodeProperties[] = [
|
export const description: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
@@ -23,12 +25,30 @@ export const description: INodeProperties[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Close Window',
|
||||||
|
value: 'close',
|
||||||
|
description: 'Close a window inside a session',
|
||||||
|
action: 'Close a window',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Create a New Browser Window',
|
name: 'Create a New Browser Window',
|
||||||
value: 'create',
|
value: 'create',
|
||||||
description: 'Create a new browser window inside a session. Can load a URL when created.',
|
description: 'Create a new browser window inside a session. Can load a URL when created.',
|
||||||
action: 'Create a window',
|
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',
|
name: 'Load URL',
|
||||||
value: 'load',
|
value: 'load',
|
||||||
@@ -41,12 +61,6 @@ export const description: INodeProperties[] = [
|
|||||||
description: 'Take a screenshot of the current window',
|
description: 'Take a screenshot of the current window',
|
||||||
action: 'Take screenshot',
|
action: 'Take screenshot',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Close Window',
|
|
||||||
value: 'close',
|
|
||||||
description: 'Close a window inside a session',
|
|
||||||
action: 'Close a window',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
default: 'create',
|
default: 'create',
|
||||||
},
|
},
|
||||||
@@ -63,11 +77,13 @@ export const description: INodeProperties[] = [
|
|||||||
displayOptions: {
|
displayOptions: {
|
||||||
show: {
|
show: {
|
||||||
resource: ['window'],
|
resource: ['window'],
|
||||||
operation: ['close', 'takeScreenshot', 'load'],
|
operation: ['close', 'takeScreenshot', 'load', 'getLiveView'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...create.description,
|
...create.description,
|
||||||
|
...list.description,
|
||||||
|
...getLiveView.description,
|
||||||
...load.description,
|
...load.description,
|
||||||
...takeScreenshot.description,
|
...takeScreenshot.description,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export const INTEGRATION_URL =
|
|||||||
export const DEFAULT_TIMEOUT_MINUTES = 10;
|
export const DEFAULT_TIMEOUT_MINUTES = 10;
|
||||||
export const MIN_TIMEOUT_MINUTES = 1;
|
export const MIN_TIMEOUT_MINUTES = 1;
|
||||||
export const MAX_TIMEOUT_MINUTES = 10080;
|
export const MAX_TIMEOUT_MINUTES = 10080;
|
||||||
|
export const DEFAULT_DOWNLOAD_TIMEOUT_SECONDS = 30;
|
||||||
export const SESSION_STATUS = {
|
export const SESSION_STATUS = {
|
||||||
INITIALIZING: 'initializing',
|
INITIALIZING: 'initializing',
|
||||||
RUNNING: 'running',
|
RUNNING: 'running',
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ const mockFileCreateResponse = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockFileEvent = `
|
|
||||||
event: fileEvent
|
|
||||||
data: {"event":"file_upload_status","status":"available","fileId":"file-123"}`;
|
|
||||||
|
|
||||||
// Mock the transport and other dependencies
|
// Mock the transport and other dependencies
|
||||||
jest.mock('../../../transport', () => {
|
jest.mock('../../../transport', () => {
|
||||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||||
@@ -30,6 +26,7 @@ describe('Test Airtop file helpers', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
(transport.apiRequest as jest.Mock).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('requestAllFiles', () => {
|
describe('requestAllFiles', () => {
|
||||||
@@ -198,42 +195,33 @@ describe('Test Airtop file helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('waitForFileInSession', () => {
|
describe('waitForFileInSession', () => {
|
||||||
it('should resolve when file is available', async () => {
|
it('should resolve when file is available in session', async () => {
|
||||||
// Create a mock stream
|
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||||
const mockStream = {
|
apiRequestMock.mockResolvedValueOnce({
|
||||||
on: jest.fn().mockImplementation((event, callback) => {
|
data: {
|
||||||
if (event === 'data') {
|
sessionIds: ['session-123', 'other-session'],
|
||||||
callback(mockFileEvent);
|
},
|
||||||
}
|
|
||||||
}),
|
|
||||||
removeAllListeners: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockHttpRequestWithAuthentication = jest.fn().mockResolvedValueOnce(mockStream);
|
|
||||||
const mockExecuteFunction = createMockExecuteFunction({});
|
|
||||||
mockExecuteFunction.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
|
|
||||||
|
|
||||||
await helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 100);
|
|
||||||
|
|
||||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('airtopApi', {
|
|
||||||
method: 'GET',
|
|
||||||
url: `${BASE_URL}/sessions/session-123/events?all=true`,
|
|
||||||
encoding: 'stream',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockStream.removeAllListeners).toHaveBeenCalled();
|
const mockExecuteFunction = createMockExecuteFunction({});
|
||||||
|
|
||||||
|
await helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 1000);
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith('GET', `${BASE_URL}/files/file-123`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should timeout if no event is received', async () => {
|
it('should timeout if file never becomes available in session', async () => {
|
||||||
// Create a mock stream
|
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||||
const mockStream = {
|
|
||||||
on: jest.fn().mockImplementation(() => {}),
|
// Mock to always return file not in session
|
||||||
removeAllListeners: jest.fn(),
|
apiRequestMock.mockResolvedValue({
|
||||||
};
|
data: {
|
||||||
const mockHttpRequestWithAuthentication = jest.fn().mockResolvedValueOnce(mockStream);
|
sessionIds: ['other-session'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const mockExecuteFunction = createMockExecuteFunction({});
|
const mockExecuteFunction = createMockExecuteFunction({});
|
||||||
mockExecuteFunction.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
|
|
||||||
|
|
||||||
const waitPromise = helpers.waitForFileInSession.call(
|
const waitPromise = helpers.waitForFileInSession.call(
|
||||||
mockExecuteFunction,
|
mockExecuteFunction,
|
||||||
@@ -244,6 +232,24 @@ describe('Test Airtop file helpers', () => {
|
|||||||
|
|
||||||
await expect(waitPromise).rejects.toThrow();
|
await expect(waitPromise).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should resolve immediately if file is already in session', async () => {
|
||||||
|
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||||
|
|
||||||
|
// Mock to return file already in session
|
||||||
|
apiRequestMock.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
sessionIds: ['session-123', 'other-session'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockExecuteFunction = createMockExecuteFunction({});
|
||||||
|
|
||||||
|
await helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 1000);
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith('GET', `${BASE_URL}/files/file-123`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pushFileToSession', () => {
|
describe('pushFileToSession', () => {
|
||||||
@@ -280,17 +286,22 @@ describe('Test Airtop file helpers', () => {
|
|||||||
const mockWindowId = 'window-123';
|
const mockWindowId = 'window-123';
|
||||||
const mockSessionId = 'session-123';
|
const mockSessionId = 'session-123';
|
||||||
|
|
||||||
await helpers.triggerFileInput.call(
|
await helpers.triggerFileInput.call(createMockExecuteFunction({}), {
|
||||||
createMockExecuteFunction({}),
|
fileId: mockFileId,
|
||||||
mockFileId,
|
windowId: mockWindowId,
|
||||||
mockWindowId,
|
sessionId: mockSessionId,
|
||||||
mockSessionId,
|
elementDescription: 'test',
|
||||||
);
|
includeHiddenElements: false,
|
||||||
|
});
|
||||||
|
|
||||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
'POST',
|
'POST',
|
||||||
`/sessions/${mockSessionId}/windows/${mockWindowId}/file-input`,
|
`/sessions/${mockSessionId}/windows/${mockWindowId}/file-input`,
|
||||||
{ fileId: mockFileId },
|
{
|
||||||
|
fileId: mockFileId,
|
||||||
|
elementDescription: 'test',
|
||||||
|
includeHiddenElements: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ describe('Test Airtop, scroll operation', () => {
|
|||||||
'/sessions/test-session-123/windows/win-123/scroll',
|
'/sessions/test-session-123/windows/win-123/scroll',
|
||||||
{
|
{
|
||||||
scrollToElement: 'the bottom of the page',
|
scrollToElement: 'the bottom of the page',
|
||||||
scrollWithin: '',
|
|
||||||
configuration: {},
|
configuration: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ describe('Test Airtop, session create operation', () => {
|
|||||||
{
|
{
|
||||||
json: {
|
json: {
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
|
data: { ...mockCreatedSession.data },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -95,6 +96,7 @@ describe('Test Airtop, session create operation', () => {
|
|||||||
{
|
{
|
||||||
json: {
|
json: {
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
|
data: { ...mockCreatedSession.data },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -125,6 +127,7 @@ describe('Test Airtop, session create operation', () => {
|
|||||||
{
|
{
|
||||||
json: {
|
json: {
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
|
data: { ...mockCreatedSession.data },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -153,6 +156,7 @@ describe('Test Airtop, session create operation', () => {
|
|||||||
{
|
{
|
||||||
json: {
|
json: {
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
|
data: { ...mockCreatedSession.data },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -181,6 +185,7 @@ describe('Test Airtop, session create operation', () => {
|
|||||||
{
|
{
|
||||||
json: {
|
json: {
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
|
data: { ...mockCreatedSession.data },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -224,6 +229,7 @@ describe('Test Airtop, session create operation', () => {
|
|||||||
{
|
{
|
||||||
json: {
|
json: {
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
|
data: { ...mockCreatedSession.data },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -256,6 +262,7 @@ describe('Test Airtop, session create operation', () => {
|
|||||||
{
|
{
|
||||||
json: {
|
json: {
|
||||||
sessionId: 'test-session-123',
|
sessionId: 'test-session-123',
|
||||||
|
data: { ...mockCreatedSession.data },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -485,7 +485,10 @@ describe('Test Airtop utils', () => {
|
|||||||
describe('createSession', () => {
|
describe('createSession', () => {
|
||||||
it('should create a session and return the session ID', async () => {
|
it('should create a session and return the session ID', async () => {
|
||||||
const result = await createSession.call(createMockExecuteFunction({}), {});
|
const result = await createSession.call(createMockExecuteFunction({}), {});
|
||||||
expect(result).toEqual({ sessionId: 'new-session-123' });
|
expect(result).toEqual({
|
||||||
|
sessionId: 'new-session-123',
|
||||||
|
data: { ...mockCreatedSession },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if no session ID is returned', async () => {
|
it('should throw an error if no session ID is returned', async () => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface IAirtopResponseWithFiles extends IAirtopResponse {
|
|||||||
pagination: {
|
pagination: {
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
};
|
};
|
||||||
|
sessionIds?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +64,14 @@ export interface IAirtopInteractionRequest extends IDataObject {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IAirtopFileInputRequest extends IDataObject {
|
||||||
|
fileId: string;
|
||||||
|
windowId: string;
|
||||||
|
sessionId: string;
|
||||||
|
elementDescription?: string;
|
||||||
|
includeHiddenElements?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IAirtopNodeExecutionData extends INodeExecutionData {
|
export interface IAirtopNodeExecutionData extends INodeExecutionData {
|
||||||
json: IAirtopResponse;
|
json: IAirtopResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user