fix(Airtop Node): Fix file upload and add support for session recording (#21248)

This commit is contained in:
Cesar Sanchez 2025-10-28 09:39:15 +00:00 committed by GitHub
parent 6ec2c820f4
commit 4e9ee11c23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 145 additions and 63 deletions

View file

@ -2,9 +2,14 @@ import pick from 'lodash/pick';
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { BASE_URL, ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants';
import { ERROR_MESSAGES, OPERATION_TIMEOUT } from '../../constants';
import { waitForSessionEvent } from '../../GenericFunctions';
import { apiRequest } from '../../transport';
import type { IAirtopResponseWithFiles, IAirtopFileInputRequest } from '../../transport/types';
import type {
IAirtopFileInputRequest,
IAirtopResponseWithFiles,
IAirtopServerEvent,
} from '../../transport/types';
/**
* Fetches all files from the Airtop API using pagination
@ -68,7 +73,6 @@ export async function pollFileUntilAvailable(
): Promise<string> {
let fileStatus = '';
const startTime = Date.now();
while (fileStatus !== 'available') {
const elapsedTime = Date.now() - startTime;
if (elapsedTime >= timeout) {
@ -80,7 +84,6 @@ export async function pollFileUntilAvailable(
const response = await apiRequest.call(this, 'GET', `/files/${fileId}`);
fileStatus = response.data?.status as string;
// Wait before the next polling attempt
await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
}
@ -106,7 +109,10 @@ export async function createAndUploadFile(
pollingFunction = pollFileUntilAvailable,
): Promise<string> {
// Create file entry
const createResponse = await apiRequest.call(this, 'POST', '/files', { fileName, fileType });
const createResponse = await apiRequest.call(this, 'POST', '/files', {
fileName,
fileType,
});
const fileId = createResponse.data?.id;
const uploadUrl = createResponse.data?.uploadUrl as string;
@ -147,24 +153,23 @@ export async function waitForFileInSession(
fileId: string,
timeout = OPERATION_TIMEOUT,
): Promise<void> {
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));
// Wait for a "file_upload_status" event with status "available" or "upload_failed"
const condition = (sessionEvent: IAirtopServerEvent) => {
const { event, status } = sessionEvent;
return (
sessionEvent.fileId === fileId &&
event === 'file_upload_status' &&
(status === 'available' || status === 'upload_failed')
);
};
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,
});
}
// wait 1 second before checking again
await new Promise((resolve) => setTimeout(resolve, 1000));
const event = await waitForSessionEvent.call(this, sessionId, condition, timeout);
if (event.status === 'upload_failed') {
const error = new NodeApiError(this.getNode(), {
message: event.eventData?.error ?? `Upload failed for File ID: ${fileId}`,
code: 500,
});
throw error;
}
}

View file

@ -39,6 +39,16 @@ export const description: INodeProperties[] = [
'Whether to automatically save the <a href="https://docs.airtop.ai/guides/how-to/saving-a-profile" target="_blank">Airtop profile</a> for this session upon termination',
displayOptions,
},
/* Session Recording */
{
displayName: 'Record Session',
name: 'record',
type: 'boolean',
default: false,
description:
'Whether to record the browser session. <a href="https://docs.airtop.ai/guides/how-to/recording-a-session" target="_blank">More details</a>.',
displayOptions,
},
{
displayName: 'Idle Timeout',
name: 'timeoutMinutes',
@ -157,6 +167,7 @@ export async function execute(
index: number,
): Promise<INodeExecutionData[]> {
const profileName = validateProfileName.call(this, index);
const record = this.getNodeParameter('record', index, false);
const timeoutMinutes = validateTimeoutMinutes.call(this, index);
const saveProfileOnTermination = validateSaveProfileOnTermination.call(this, index, profileName);
const { proxy } = validateProxy.call(this, index);
@ -175,6 +186,7 @@ export async function execute(
timeoutMinutes,
proxy,
solveCaptcha,
record,
...(extensionIds.length > 0 ? { extensionIds } : {}),
},
};

View file

@ -1,5 +1,5 @@
import * as helpers from '../../../actions/file/helpers';
import { BASE_URL } from '../../../constants';
import * as GenericFunctions from '../../../GenericFunctions';
import * as transport from '../../../transport';
import { createMockExecuteFunction } from '../helpers';
@ -19,14 +19,24 @@ jest.mock('../../../transport', () => {
};
});
jest.mock('../../../GenericFunctions', () => {
const originalModule = jest.requireActual<typeof GenericFunctions>('../../../GenericFunctions');
return {
...originalModule,
waitForSessionEvent: jest.fn(),
};
});
describe('Test Airtop file helpers', () => {
afterAll(() => {
jest.unmock('../../../transport');
jest.unmock('../../../GenericFunctions');
});
afterEach(() => {
jest.clearAllMocks();
(transport.apiRequest as jest.Mock).mockReset();
(GenericFunctions.waitForSessionEvent as jest.Mock).mockReset();
});
describe('requestAllFiles', () => {
@ -195,60 +205,75 @@ describe('Test Airtop file helpers', () => {
});
describe('waitForFileInSession', () => {
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'],
},
});
it('should resolve when file_upload_status event with available status is received', async () => {
const waitForSessionEventMock = GenericFunctions.waitForSessionEvent as jest.Mock;
const mockEvent = {
event: 'file_upload_status',
status: 'available',
fileId: 'file-123',
};
waitForSessionEventMock.mockResolvedValueOnce(mockEvent);
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 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({});
const waitPromise = helpers.waitForFileInSession.call(
mockExecuteFunction,
expect(waitForSessionEventMock).toHaveBeenCalledTimes(1);
expect(waitForSessionEventMock).toHaveBeenCalledWith(
'session-123',
'file-123',
100,
expect.any(Function),
1000,
);
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'],
it('should throw error when uploading a file with invalid file format', async () => {
const waitForSessionEventMock = GenericFunctions.waitForSessionEvent as jest.Mock;
const mockEvent = {
event: 'file_upload_status',
status: 'upload_failed',
fileId: 'file-123',
eventData: {
error: 'Upload failed due to invalid file format',
},
});
};
waitForSessionEventMock.mockResolvedValueOnce(mockEvent);
const mockExecuteFunction = createMockExecuteFunction({});
await helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 1000);
await expect(
helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 1000),
).rejects.toMatchObject({ description: 'Upload failed due to invalid file format' });
});
expect(apiRequestMock).toHaveBeenCalledTimes(1);
expect(apiRequestMock).toHaveBeenCalledWith('GET', `${BASE_URL}/files/file-123`);
it('should throw error when upload_failed status is received', async () => {
const waitForSessionEventMock = GenericFunctions.waitForSessionEvent as jest.Mock;
const mockEvent = {
fileId: 'file-123',
event: 'file_upload_status',
status: 'upload_failed',
eventData: {
error: 'Upload failed for File ID: file-123',
},
};
waitForSessionEventMock.mockResolvedValueOnce(mockEvent);
const mockExecuteFunction = createMockExecuteFunction({});
// the service should throw an error description 'Upload failed for File ID: file-123'
await expect(
helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 1000),
).rejects.toMatchObject({ description: 'Upload failed for File ID: file-123' });
});
it('should timeout if no matching event is received', async () => {
const waitForSessionEventMock = GenericFunctions.waitForSessionEvent as jest.Mock;
waitForSessionEventMock.mockRejectedValueOnce(new Error('Timeout reached'));
const mockExecuteFunction = createMockExecuteFunction({});
await expect(
helpers.waitForFileInSession.call(mockExecuteFunction, 'session-123', 'file-123', 100),
).rejects.toThrow('Timeout reached');
});
});

View file

@ -11,6 +11,7 @@ const baseNodeParameters = {
resource: 'session',
operation: 'create',
profileName: 'test-profile',
record: false,
timeoutMinutes: 10,
saveProfileOnTermination: false,
};
@ -52,6 +53,7 @@ describe('Test Airtop, session create operation', () => {
profileName: 'test-profile',
solveCaptcha: false,
timeoutMinutes: 10,
record: false,
proxy: false,
},
});
@ -83,6 +85,7 @@ describe('Test Airtop, session create operation', () => {
profileName: 'test-profile',
solveCaptcha: false,
timeoutMinutes: 10,
record: false,
proxy: false,
},
});
@ -119,6 +122,7 @@ describe('Test Airtop, session create operation', () => {
profileName: 'test-profile',
solveCaptcha: false,
timeoutMinutes: 10,
record: false,
proxy: true,
},
});
@ -148,6 +152,7 @@ describe('Test Airtop, session create operation', () => {
profileName: 'test-profile',
solveCaptcha: false,
timeoutMinutes: 10,
record: false,
proxy: { country: 'US', sticky: true },
},
});
@ -177,6 +182,7 @@ describe('Test Airtop, session create operation', () => {
profileName: 'test-profile',
solveCaptcha: false,
timeoutMinutes: 10,
record: false,
proxy: 'http://proxy.example.com:8080',
},
});
@ -221,6 +227,7 @@ describe('Test Airtop, session create operation', () => {
profileName: 'test-profile',
solveCaptcha: true,
timeoutMinutes: 10,
record: false,
proxy: false,
},
});
@ -253,11 +260,44 @@ describe('Test Airtop, session create operation', () => {
profileName: 'test-profile',
solveCaptcha: false,
timeoutMinutes: 10,
record: false,
proxy: false,
extensionIds: ['extId1', 'extId2'],
},
});
expect(result).toEqual([
{
json: {
sessionId: 'test-session-123',
data: { ...mockCreatedSession.data },
},
},
]);
});
/**
* Session recording
*/
it('should create a session with recording enabled', async () => {
const nodeParameters = {
...baseNodeParameters,
record: true,
proxy: 'none',
};
const result = await create.execute.call(createMockExecuteFunction(nodeParameters), 0);
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/sessions', {
configuration: {
profileName: 'test-profile',
solveCaptcha: false,
timeoutMinutes: 10,
record: true,
proxy: false,
},
});
expect(result).toEqual([
{
json: {

View file

@ -1,9 +1,9 @@
import type {
IDataObject,
IExecuteFunctions,
ILoadOptionsFunctions,
IHttpRequestMethods,
IHttpRequestOptions,
ILoadOptionsFunctions,
} from 'n8n-workflow';
import type { IAirtopResponse } from './types';