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:
Cesar Sanchez
2025-07-04 18:56:14 +01:00
committed by GitHub
parent bb9679c4fa
commit 621745e291
21 changed files with 587 additions and 163 deletions

View File

@@ -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;
}

View File

@@ -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',
};

View File

@@ -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 };
}

View File

@@ -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']),
);
}
/**

View File

@@ -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,

View File

@@ -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({

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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,
];

View File

@@ -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 });
}

View File

@@ -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,
});
}

View File

@@ -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,
];

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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',

View File

@@ -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,
},
);
});
});

View File

@@ -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: {},
},
);

View File

@@ -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 },
},
},
]);

View File

@@ -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);
});
});

View File

@@ -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 () => {

View File

@@ -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;
}