diff --git a/packages/nodes-base/nodes/Airtop/GenericFunctions.ts b/packages/nodes-base/nodes/Airtop/GenericFunctions.ts index 8535a5a922..28bac876ae 100644 --- a/packages/nodes-base/nodes/Airtop/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Airtop/GenericFunctions.ts @@ -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(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 { + const url = `${BASE_URL}/sessions/${sessionId}/events?all=true`; + let stream: Stream; + + const eventPromise = new Promise(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((_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; +} diff --git a/packages/nodes-base/nodes/Airtop/actions/common/fields.ts b/packages/nodes-base/nodes/Airtop/actions/common/fields.ts index 828b2b198e..1b3d82e99c 100644 --- a/packages/nodes-base/nodes/Airtop/actions/common/fields.ts +++ b/packages/nodes-base/nodes/Airtop/actions/common/fields.ts @@ -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', +}; diff --git a/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts b/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts index 158f00587c..b08ab0aa8b 100644 --- a/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts +++ b/packages/nodes-base/nodes/Airtop/actions/common/session.utils.ts @@ -25,22 +25,33 @@ export async function executeRequestWithSessionManagement( body: IDataObject; }, ): Promise { - 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 }; } diff --git a/packages/nodes-base/nodes/Airtop/actions/file/helpers.ts b/packages/nodes-base/nodes/Airtop/actions/file/helpers.ts index 6989fa9036..2e8bc8de27 100644 --- a/packages/nodes-base/nodes/Airtop/actions/file/helpers.ts +++ b/packages/nodes-base/nodes/Airtop/actions/file/helpers.ts @@ -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(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 { - const url = `${BASE_URL}/sessions/${sessionId}/events?all=true`; + const url = `${BASE_URL}/files/${fileId}`; + const isFileInSession = async (): Promise => { + const fileInfo = (await apiRequest.call(this, 'GET', url)) as IAirtopResponseWithFiles; + return Boolean(fileInfo.data?.sessionIds?.includes(sessionId)); + }; - const fileReadyPromise = new Promise(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((_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 { - 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']), + ); } /** diff --git a/packages/nodes-base/nodes/Airtop/actions/file/load.operation.ts b/packages/nodes-base/nodes/Airtop/actions/file/load.operation.ts index 898b472f1d..43d6ace287 100644 --- a/packages/nodes-base/nodes/Airtop/actions/file/load.operation.ts +++ b/packages/nodes-base/nodes/Airtop/actions/file/load.operation.ts @@ -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, diff --git a/packages/nodes-base/nodes/Airtop/actions/file/upload.operation.ts b/packages/nodes-base/nodes/Airtop/actions/file/upload.operation.ts index 8949503936..238c7ed271 100644 --- a/packages/nodes-base/nodes/Airtop/actions/file/upload.operation.ts +++ b/packages/nodes-base/nodes/Airtop/actions/file/upload.operation.ts @@ -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({ diff --git a/packages/nodes-base/nodes/Airtop/actions/interaction/scroll.operation.ts b/packages/nodes-base/nodes/Airtop/actions/interaction/scroll.operation.ts index f70d010517..4bc7cf04e6 100644 --- a/packages/nodes-base/nodes/Airtop/actions/interaction/scroll.operation.ts +++ b/packages/nodes-base/nodes/Airtop/actions/interaction/scroll.operation.ts @@ -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); diff --git a/packages/nodes-base/nodes/Airtop/actions/router.ts b/packages/nodes-base/nodes/Airtop/actions/router.ts index 5c3e0ce429..a98af6b936 100644 --- a/packages/nodes-base/nodes/Airtop/actions/router.ts +++ b/packages/nodes-base/nodes/Airtop/actions/router.ts @@ -51,7 +51,7 @@ export async function router(this: IExecuteFunctions): Promise { + 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, + }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts b/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts index ceb922dc60..751ef09d9e 100644 --- a/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts +++ b/packages/nodes-base/nodes/Airtop/actions/window/Window.resource.ts @@ -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, ]; diff --git a/packages/nodes-base/nodes/Airtop/actions/window/getLiveView.operation.ts b/packages/nodes-base/nodes/Airtop/actions/window/getLiveView.operation.ts new file mode 100644 index 0000000000..a32c027fc7 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/window/getLiveView.operation.ts @@ -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 { + const { sessionId, windowId } = validateSessionAndWindowId.call(this, index); + const additionalFields = this.getNodeParameter('additionalFields', index); + + const queryParams: Record = {}; + + 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 }); +} diff --git a/packages/nodes-base/nodes/Airtop/actions/window/list.operation.ts b/packages/nodes-base/nodes/Airtop/actions/window/list.operation.ts new file mode 100644 index 0000000000..09a5d3dc6e --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/actions/window/list.operation.ts @@ -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 { + 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 }); +} diff --git a/packages/nodes-base/nodes/Airtop/constants.ts b/packages/nodes-base/nodes/Airtop/constants.ts index fd47739c5a..5b7dccbb84 100644 --- a/packages/nodes-base/nodes/Airtop/constants.ts +++ b/packages/nodes-base/nodes/Airtop/constants.ts @@ -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', diff --git a/packages/nodes-base/nodes/Airtop/test/node/file/helpers.test.ts b/packages/nodes-base/nodes/Airtop/test/node/file/helpers.test.ts index 696d617289..1e165e90e5 100644 --- a/packages/nodes-base/nodes/Airtop/test/node/file/helpers.test.ts +++ b/packages/nodes-base/nodes/Airtop/test/node/file/helpers.test.ts @@ -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('../../../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, + }, ); }); }); diff --git a/packages/nodes-base/nodes/Airtop/test/node/interaction/scroll.test.ts b/packages/nodes-base/nodes/Airtop/test/node/interaction/scroll.test.ts index 126c2b330e..255b236998 100644 --- a/packages/nodes-base/nodes/Airtop/test/node/interaction/scroll.test.ts +++ b/packages/nodes-base/nodes/Airtop/test/node/interaction/scroll.test.ts @@ -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: {}, }, ); diff --git a/packages/nodes-base/nodes/Airtop/test/node/session/create.test.ts b/packages/nodes-base/nodes/Airtop/test/node/session/create.test.ts index 6130793edc..5beeda1f95 100644 --- a/packages/nodes-base/nodes/Airtop/test/node/session/create.test.ts +++ b/packages/nodes-base/nodes/Airtop/test/node/session/create.test.ts @@ -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 }, }, }, ]); diff --git a/packages/nodes-base/nodes/Airtop/test/node/session/waitForDownload.test.ts b/packages/nodes-base/nodes/Airtop/test/node/session/waitForDownload.test.ts new file mode 100644 index 0000000000..2e5221cb96 --- /dev/null +++ b/packages/nodes-base/nodes/Airtop/test/node/session/waitForDownload.test.ts @@ -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('../../../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); + }); +}); diff --git a/packages/nodes-base/nodes/Airtop/test/utils.test.ts b/packages/nodes-base/nodes/Airtop/test/utils.test.ts index 21169b916d..30bdb2e28a 100644 --- a/packages/nodes-base/nodes/Airtop/test/utils.test.ts +++ b/packages/nodes-base/nodes/Airtop/test/utils.test.ts @@ -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 () => { diff --git a/packages/nodes-base/nodes/Airtop/transport/types.ts b/packages/nodes-base/nodes/Airtop/transport/types.ts index 7face1baf5..2990529b83 100644 --- a/packages/nodes-base/nodes/Airtop/transport/types.ts +++ b/packages/nodes-base/nodes/Airtop/transport/types.ts @@ -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; }