mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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 type Stream from 'node:stream';
|
||||
|
||||
import { SESSION_MODE } from './actions/common/fields';
|
||||
import type { TScrollingMode } from './constants';
|
||||
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, IAirtopSessionResponse } from './transport/types';
|
||||
import type {
|
||||
IAirtopResponse,
|
||||
IAirtopServerEvent,
|
||||
IAirtopSessionResponse,
|
||||
} from './transport/types';
|
||||
|
||||
/**
|
||||
* Validate a required string field
|
||||
@@ -364,7 +376,7 @@ export async function createSession(
|
||||
this: IExecuteFunctions,
|
||||
parameters: IDataObject,
|
||||
timeout = OPERATION_TIMEOUT,
|
||||
): Promise<{ sessionId: string }> {
|
||||
): Promise<{ sessionId: string; data: IAirtopSessionResponse }> {
|
||||
// Request session creation
|
||||
const response = (await apiRequest.call(
|
||||
this,
|
||||
@@ -401,7 +413,12 @@ export async function createSession(
|
||||
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.`);
|
||||
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;
|
||||
},
|
||||
): Promise<IAirtopResponse> {
|
||||
const { sessionId, windowId } = shouldCreateNewSession.call(this, index)
|
||||
? await createSessionAndWindow.call(this, index)
|
||||
: validateSessionAndWindowId.call(this, index);
|
||||
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 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);
|
||||
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);
|
||||
validateAirtopApiResponse(this.getNode(), response);
|
||||
|
||||
if (shouldTerminateSession) {
|
||||
await apiRequest.call(this, 'DELETE', `/sessions/${sessionId}`);
|
||||
this.logger.info(`[${this.getNode().name}] Session terminated.`);
|
||||
return 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;
|
||||
}
|
||||
|
||||
return { sessionId, windowId, ...response };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import pick from 'lodash/pick';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { jsonParse, NodeApiError } from 'n8n-workflow';
|
||||
import type { Stream } from 'stream';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { BASE_URL, ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants';
|
||||
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
|
||||
@@ -132,27 +132,11 @@ export async function createAndUploadFile(
|
||||
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 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)
|
||||
* @returns Promise that resolves when a file in the session becomes available
|
||||
* @throws NodeApiError if the timeout is reached before a file becomes available
|
||||
@@ -163,61 +147,25 @@ export async function waitForFileInSession(
|
||||
fileId: string,
|
||||
timeout = OPERATION_TIMEOUT,
|
||||
): 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 stream = (await this.helpers.httpRequestWithAuthentication.call(this, 'airtopApi', {
|
||||
method: 'GET',
|
||||
url,
|
||||
encoding: 'stream',
|
||||
})) as Stream;
|
||||
|
||||
const close = () => {
|
||||
resolve();
|
||||
stream.removeAllListeners();
|
||||
};
|
||||
|
||||
const onError = (errorMessage: string) => {
|
||||
const error = new NodeApiError(this.getNode(), {
|
||||
message: errorMessage,
|
||||
description: 'Failed to upload file',
|
||||
const startTime = Date.now();
|
||||
while (!(await isFileInSession())) {
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
// throw error if timeout is reached
|
||||
if (elapsedTime >= timeout) {
|
||||
throw new NodeApiError(this.getNode(), {
|
||||
message: ERROR_MESSAGES.TIMEOUT_REACHED,
|
||||
code: 500,
|
||||
});
|
||||
reject(error);
|
||||
stream.removeAllListeners();
|
||||
};
|
||||
|
||||
stream.on('data', (data: Uint8Array) => {
|
||||
const event = parseEvent(data.toString());
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
// handle error
|
||||
if (event?.eventData?.error) {
|
||||
onError(event.eventData.error);
|
||||
return;
|
||||
}
|
||||
// handle file available
|
||||
if (isFileAvailable(event, fileId)) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new NodeApiError(this.getNode(), {
|
||||
message: ERROR_MESSAGES.TIMEOUT_REACHED,
|
||||
code: 500,
|
||||
}),
|
||||
),
|
||||
timeout,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.race([fileReadyPromise, timeoutPromise]);
|
||||
}
|
||||
// wait 1 second before checking again
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,15 +197,14 @@ export async function pushFileToSession(
|
||||
*/
|
||||
export async function triggerFileInput(
|
||||
this: IExecuteFunctions,
|
||||
fileId: string,
|
||||
windowId: string,
|
||||
sessionId: string,
|
||||
elementDescription = '',
|
||||
request: IAirtopFileInputRequest,
|
||||
): Promise<void> {
|
||||
await apiRequest.call(this, 'POST', `/sessions/${sessionId}/windows/${windowId}/file-input`, {
|
||||
fileId,
|
||||
...(elementDescription ? { elementDescription } : {}),
|
||||
});
|
||||
await apiRequest.call(
|
||||
this,
|
||||
'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 { pushFileToSession, triggerFileInput } from './helpers';
|
||||
import { sessionIdField, windowIdField, elementDescriptionField } from '../common/fields';
|
||||
import {
|
||||
sessionIdField,
|
||||
windowIdField,
|
||||
elementDescriptionField,
|
||||
includeHiddenElementsField,
|
||||
} from '../common/fields';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
@@ -37,6 +42,10 @@ export const description: INodeProperties[] = [
|
||||
placeholder: 'e.g. the file upload selection box',
|
||||
displayOptions,
|
||||
},
|
||||
{
|
||||
...includeHiddenElementsField,
|
||||
displayOptions,
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
@@ -47,10 +56,21 @@ export async function execute(
|
||||
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);
|
||||
await triggerFileInput.call(this, {
|
||||
fileId,
|
||||
windowId,
|
||||
sessionId,
|
||||
elementDescription,
|
||||
includeHiddenElements,
|
||||
});
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
sessionId,
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
createFileBuffer,
|
||||
} from './helpers';
|
||||
import { validateRequiredStringField } from '../../GenericFunctions';
|
||||
import { sessionIdField, windowIdField, elementDescriptionField } from '../common/fields';
|
||||
import {
|
||||
sessionIdField,
|
||||
windowIdField,
|
||||
elementDescriptionField,
|
||||
includeHiddenElementsField,
|
||||
} from '../common/fields';
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
@@ -130,6 +135,15 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...includeHiddenElementsField,
|
||||
displayOptions: {
|
||||
show: {
|
||||
triggerFileInputParameter: [true],
|
||||
...displayOptions.show,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
@@ -149,7 +163,11 @@ export async function execute(
|
||||
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;
|
||||
|
||||
@@ -160,7 +178,13 @@ export async function execute(
|
||||
await pushFileToSession.call(this, fileId, sessionId);
|
||||
|
||||
if (triggerFileInputParameter) {
|
||||
await triggerFileInput.call(this, fileId, windowId, sessionId, elementDescription);
|
||||
await triggerFileInput.call(this, {
|
||||
fileId,
|
||||
windowId,
|
||||
sessionId,
|
||||
elementDescription,
|
||||
includeHiddenElements,
|
||||
});
|
||||
}
|
||||
|
||||
return this.helpers.returnJsonArray({
|
||||
|
||||
@@ -59,7 +59,7 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Scroll To Page Edges',
|
||||
displayName: 'Scroll To Edge',
|
||||
name: 'scrollToEdge',
|
||||
type: 'fixedCollection',
|
||||
default: {},
|
||||
@@ -172,7 +172,6 @@ export const description: INodeProperties[] = [
|
||||
show: {
|
||||
resource: ['interaction'],
|
||||
operation: ['scroll'],
|
||||
scrollingMode: ['automatic'],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -206,7 +205,8 @@ export async function execute(
|
||||
...(!isAutomatic ? { scrollBy } : {}),
|
||||
// when scrollingMode is 'Automatic'
|
||||
...(isAutomatic ? { scrollToElement } : {}),
|
||||
...(isAutomatic ? { scrollWithin } : {}),
|
||||
// when scrollWithin is defined
|
||||
...(scrollWithin ? { scrollWithin } : {}),
|
||||
};
|
||||
|
||||
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
|
||||
if (isCalledAsTool && !['session', 'window'].includes(resource)) {
|
||||
if (isCalledAsTool) {
|
||||
responseData = cleanOutputForToolUse(responseData);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ 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 };
|
||||
export { create, save, terminate, waitForDownload };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
@@ -37,10 +38,17 @@ export const description: INodeProperties[] = [
|
||||
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,
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
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 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 };
|
||||
export { create, close, takeScreenshot, load, list, getLiveView };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
@@ -23,12 +25,30 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
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',
|
||||
@@ -41,12 +61,6 @@ export const description: INodeProperties[] = [
|
||||
description: 'Take a screenshot of the current window',
|
||||
action: 'Take screenshot',
|
||||
},
|
||||
{
|
||||
name: 'Close Window',
|
||||
value: 'close',
|
||||
description: 'Close a window inside a session',
|
||||
action: 'Close a window',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
@@ -63,11 +77,13 @@ export const description: INodeProperties[] = [
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['window'],
|
||||
operation: ['close', 'takeScreenshot', 'load'],
|
||||
operation: ['close', 'takeScreenshot', 'load', 'getLiveView'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...create.description,
|
||||
...list.description,
|
||||
...getLiveView.description,
|
||||
...load.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 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',
|
||||
|
||||
@@ -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
|
||||
jest.mock('../../../transport', () => {
|
||||
const originalModule = jest.requireActual<typeof transport>('../../../transport');
|
||||
@@ -30,6 +26,7 @@ describe('Test Airtop file helpers', () => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(transport.apiRequest as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
describe('requestAllFiles', () => {
|
||||
@@ -198,42 +195,33 @@ describe('Test Airtop file helpers', () => {
|
||||
});
|
||||
|
||||
describe('waitForFileInSession', () => {
|
||||
it('should resolve when file is available', async () => {
|
||||
// Create a mock stream
|
||||
const mockStream = {
|
||||
on: jest.fn().mockImplementation((event, callback) => {
|
||||
if (event === 'data') {
|
||||
callback(mockFileEvent);
|
||||
}
|
||||
}),
|
||||
removeAllListeners: jest.fn(),
|
||||
};
|
||||
|
||||
const mockHttpRequestWithAuthentication = jest.fn().mockResolvedValueOnce(mockStream);
|
||||
const mockExecuteFunction = createMockExecuteFunction({});
|
||||
mockExecuteFunction.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
|
||||
|
||||
await helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 100);
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('airtopApi', {
|
||||
method: 'GET',
|
||||
url: `${BASE_URL}/sessions/session-123/events?all=true`,
|
||||
encoding: 'stream',
|
||||
it('should resolve when file is available in session', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
apiRequestMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
sessionIds: ['session-123', 'other-session'],
|
||||
},
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// Create a mock stream
|
||||
const mockStream = {
|
||||
on: jest.fn().mockImplementation(() => {}),
|
||||
removeAllListeners: jest.fn(),
|
||||
};
|
||||
const mockHttpRequestWithAuthentication = jest.fn().mockResolvedValueOnce(mockStream);
|
||||
it('should timeout if file never becomes available in session', async () => {
|
||||
const apiRequestMock = transport.apiRequest as jest.Mock;
|
||||
|
||||
// Mock to always return file not in session
|
||||
apiRequestMock.mockResolvedValue({
|
||||
data: {
|
||||
sessionIds: ['other-session'],
|
||||
},
|
||||
});
|
||||
|
||||
const mockExecuteFunction = createMockExecuteFunction({});
|
||||
mockExecuteFunction.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
|
||||
|
||||
const waitPromise = helpers.waitForFileInSession.call(
|
||||
mockExecuteFunction,
|
||||
@@ -244,6 +232,24 @@ describe('Test Airtop file helpers', () => {
|
||||
|
||||
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', () => {
|
||||
@@ -280,17 +286,22 @@ describe('Test Airtop file helpers', () => {
|
||||
const mockWindowId = 'window-123';
|
||||
const mockSessionId = 'session-123';
|
||||
|
||||
await helpers.triggerFileInput.call(
|
||||
createMockExecuteFunction({}),
|
||||
mockFileId,
|
||||
mockWindowId,
|
||||
mockSessionId,
|
||||
);
|
||||
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 },
|
||||
{
|
||||
fileId: mockFileId,
|
||||
elementDescription: 'test',
|
||||
includeHiddenElements: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,6 @@ describe('Test Airtop, scroll operation', () => {
|
||||
'/sessions/test-session-123/windows/win-123/scroll',
|
||||
{
|
||||
scrollToElement: 'the bottom of the page',
|
||||
scrollWithin: '',
|
||||
configuration: {},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('Test Airtop, session create operation', () => {
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -95,6 +96,7 @@ describe('Test Airtop, session create operation', () => {
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -125,6 +127,7 @@ describe('Test Airtop, session create operation', () => {
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -153,6 +156,7 @@ describe('Test Airtop, session create operation', () => {
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -181,6 +185,7 @@ describe('Test Airtop, session create operation', () => {
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -224,6 +229,7 @@ describe('Test Airtop, session create operation', () => {
|
||||
{
|
||||
json: {
|
||||
sessionId: 'test-session-123',
|
||||
data: { ...mockCreatedSession.data },
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -256,6 +262,7 @@ describe('Test Airtop, session create operation', () => {
|
||||
{
|
||||
json: {
|
||||
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', () => {
|
||||
it('should create a session and return the session ID', async () => {
|
||||
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 () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface IAirtopResponseWithFiles extends IAirtopResponse {
|
||||
pagination: {
|
||||
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 {
|
||||
json: IAirtopResponse;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user