mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
fix(Airtop Node): Fix file upload and add support for session recording (#21248)
This commit is contained in:
parent
6ec2c820f4
commit
4e9ee11c23
5 changed files with 145 additions and 63 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
IHttpRequestMethods,
|
||||
IHttpRequestOptions,
|
||||
ILoadOptionsFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { IAirtopResponse } from './types';
|
||||
|
|
|
|||
Loading…
Reference in a new issue