diff --git a/packages/agent-client/src/action-fields/action-field-file-list.ts b/packages/agent-client/src/action-fields/action-field-file-list.ts new file mode 100644 index 0000000000..b42344ded3 --- /dev/null +++ b/packages/agent-client/src/action-fields/action-field-file-list.ts @@ -0,0 +1,25 @@ +import ActionField from './action-field'; +import ActionFieldFile, { type FileInput } from './action-field-file'; + +export default class ActionFieldFileList extends ActionField { + async add(file: FileInput) { + const values = (this.field?.getValue() as string[]) || []; + await this.setValue([...values, ActionFieldFile.makeDataUri(file)]); + } + + async remove(fileName: string) { + const values = (this.field?.getValue() as string[]) || []; + const nameParam = `name=${encodeURIComponent(fileName)}`; + const filtered = values.filter(uri => { + const [metadata] = uri.split(';base64,'); + + return !metadata.split(';').some(part => part === nameParam); + }); + + if (filtered.length === values.length) { + throw new Error(`File "${fileName}" is not in the list`); + } + + await this.setValue(filtered); + } +} diff --git a/packages/agent-client/src/action-fields/action-field-file.ts b/packages/agent-client/src/action-fields/action-field-file.ts new file mode 100644 index 0000000000..5c4020c823 --- /dev/null +++ b/packages/agent-client/src/action-fields/action-field-file.ts @@ -0,0 +1,24 @@ +import ActionField from './action-field'; + +export type FileInput = { mimeType: string; buffer: Buffer; name: string; charset?: string }; + +export default class ActionFieldFile extends ActionField { + async fill(file?: FileInput | null) { + if (this.isValueUndefinedOrNull(file)) { + await this.setValue(file); + + return; + } + + await this.setValue(ActionFieldFile.makeDataUri(file)); + } + + static makeDataUri(file: FileInput): string { + const { mimeType, buffer, ...rest } = file; + const params = Object.entries(rest) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join(';'); + + return `data:${mimeType};${params};base64,${buffer.toString('base64')}`; + } +} diff --git a/packages/agent-client/src/domains/action.ts b/packages/agent-client/src/domains/action.ts index 690b9d67f0..dfd278601d 100644 --- a/packages/agent-client/src/domains/action.ts +++ b/packages/agent-client/src/domains/action.ts @@ -8,6 +8,8 @@ import ActionFieldColorPicker from '../action-fields/action-field-color-picker'; import ActionFieldDate from '../action-fields/action-field-date'; import ActionFieldDropdown from '../action-fields/action-field-dropdown'; import ActionFieldEnum from '../action-fields/action-field-enum'; +import ActionFieldFile from '../action-fields/action-field-file'; +import ActionFieldFileList from '../action-fields/action-field-file-list'; import ActionFieldJson from '../action-fields/action-field-json'; import ActionFieldNumber from '../action-fields/action-field-number'; import ActionFieldNumberList from '../action-fields/action-field-number-list'; @@ -124,6 +126,11 @@ export default class Action { return this.getDateField(fieldName); case 'Enum': return this.getEnumField(fieldName); + case 'File': + return this.getFileField(fieldName); + case 'FileList': + case '["File"]': + return this.getFileListField(fieldName); case 'String': default: return this.getFieldString(fieldName); @@ -174,6 +181,14 @@ export default class Action { return new ActionFieldEnum(fieldName, this.fieldsFormStates); } + getFileField(fieldName: string): ActionFieldFile { + return new ActionFieldFile(fieldName, this.fieldsFormStates); + } + + getFileListField(fieldName: string): ActionFieldFileList { + return new ActionFieldFileList(fieldName, this.fieldsFormStates); + } + getRadioGroupField(fieldName: string): ActionFieldRadioGroup { return new ActionFieldRadioGroup(fieldName, this.fieldsFormStates); } diff --git a/packages/agent-client/test/action-fields/action-fields.test.ts b/packages/agent-client/test/action-fields/action-fields.test.ts index d33fd608a4..494a5dd190 100644 --- a/packages/agent-client/test/action-fields/action-fields.test.ts +++ b/packages/agent-client/test/action-fields/action-fields.test.ts @@ -6,6 +6,8 @@ import ActionFieldColorPicker from '../../src/action-fields/action-field-color-p import ActionFieldDate from '../../src/action-fields/action-field-date'; import ActionFieldDropdown from '../../src/action-fields/action-field-dropdown'; import ActionFieldEnum from '../../src/action-fields/action-field-enum'; +import ActionFieldFile from '../../src/action-fields/action-field-file'; +import ActionFieldFileList from '../../src/action-fields/action-field-file-list'; import ActionFieldJson from '../../src/action-fields/action-field-json'; import ActionFieldNumber from '../../src/action-fields/action-field-number'; import ActionFieldNumberList from '../../src/action-fields/action-field-number-list'; @@ -805,6 +807,358 @@ describe('ActionField implementations', () => { }); }); + describe('ActionFieldFile', () => { + beforeEach(async () => { + await setupFields([ + { field: 'document', type: 'File', isRequired: false, isReadOnly: false, value: null }, + ]); + }); + + it('should fill with a file object and produce a data URI', async () => { + const field = new ActionFieldFile('document', fieldFormStates); + const file = { mimeType: 'application/pdf', buffer: Buffer.from('test'), name: 'doc.pdf' }; + httpRequester.query.mockResolvedValue({ + fields: [ + { + field: 'document', + type: 'File', + isRequired: false, + isReadOnly: false, + value: 'data:application/pdf;name=doc.pdf;base64,dGVzdA==', + }, + ], + layout: [], + }); + + await field.fill(file); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + attributes: expect.objectContaining({ + fields: [ + expect.objectContaining({ + field: 'document', + value: 'data:application/pdf;name=doc.pdf;base64,dGVzdA==', + }), + ], + }), + }), + }), + }), + ); + }); + + it('should fill with a file object including charset', async () => { + const field = new ActionFieldFile('document', fieldFormStates); + const file = { + mimeType: 'text/plain', + buffer: Buffer.from('hello'), + name: 'a.txt', + charset: 'utf-8', + }; + httpRequester.query.mockResolvedValue({ + fields: [ + { + field: 'document', + type: 'File', + isRequired: false, + isReadOnly: false, + value: 'data:text/plain;name=a.txt;charset=utf-8;base64,aGVsbG8=', + }, + ], + layout: [], + }); + + await field.fill(file); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + attributes: expect.objectContaining({ + fields: [ + expect.objectContaining({ + field: 'document', + value: 'data:text/plain;name=a.txt;charset=utf-8;base64,aGVsbG8=', + }), + ], + }), + }), + }), + }), + ); + }); + + it('should fill with undefined', async () => { + const field = new ActionFieldFile('document', fieldFormStates); + httpRequester.query.mockResolvedValue({ + fields: [ + { + field: 'document', + type: 'File', + isRequired: false, + isReadOnly: false, + value: undefined, + }, + ], + layout: [], + }); + + await field.fill(undefined); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + attributes: expect.objectContaining({ + fields: [expect.objectContaining({ field: 'document', value: undefined })], + }), + }), + }), + }), + ); + }); + + it('should fill with null', async () => { + const field = new ActionFieldFile('document', fieldFormStates); + httpRequester.query.mockResolvedValue({ + fields: [ + { + field: 'document', + type: 'File', + isRequired: false, + isReadOnly: false, + value: null, + }, + ], + layout: [], + }); + + await field.fill(null); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + attributes: expect.objectContaining({ + fields: [expect.objectContaining({ field: 'document', value: null })], + }), + }), + }), + }), + ); + }); + }); + + describe('ActionFieldFileList', () => { + it('should add a file to an existing list', async () => { + await setupFields([ + { + field: 'attachments', + type: 'FileList', + isRequired: false, + isReadOnly: false, + value: ['data:image/png;name=a.png;base64,AAAA'], + }, + ]); + const field = new ActionFieldFileList('attachments', fieldFormStates); + httpRequester.query.mockResolvedValue({ + fields: [ + { + field: 'attachments', + type: 'FileList', + isRequired: false, + isReadOnly: false, + value: [ + 'data:image/png;name=a.png;base64,AAAA', + 'data:image/jpeg;name=b.jpg;base64,Yg==', + ], + }, + ], + layout: [], + }); + + await field.add({ mimeType: 'image/jpeg', buffer: Buffer.from('b'), name: 'b.jpg' }); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + attributes: expect.objectContaining({ + fields: [ + expect.objectContaining({ + field: 'attachments', + value: [ + 'data:image/png;name=a.png;base64,AAAA', + 'data:image/jpeg;name=b.jpg;base64,Yg==', + ], + }), + ], + }), + }), + }), + }), + ); + }); + + it('should add a file to an empty list', async () => { + await setupFields([ + { + field: 'attachments', + type: 'FileList', + isRequired: false, + isReadOnly: false, + value: null, + }, + ]); + const field = new ActionFieldFileList('attachments', fieldFormStates); + httpRequester.query.mockResolvedValue({ + fields: [ + { + field: 'attachments', + type: 'FileList', + isRequired: false, + isReadOnly: false, + value: ['data:image/png;name=a.png;base64,dGVzdA=='], + }, + ], + layout: [], + }); + + await field.add({ mimeType: 'image/png', buffer: Buffer.from('test'), name: 'a.png' }); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + attributes: expect.objectContaining({ + fields: [ + expect.objectContaining({ + field: 'attachments', + value: ['data:image/png;name=a.png;base64,dGVzdA=='], + }), + ], + }), + }), + }), + }), + ); + }); + + it('should remove a file by name', async () => { + await setupFields([ + { + field: 'attachments', + type: 'FileList', + isRequired: false, + isReadOnly: false, + value: [ + 'data:image/png;name=a.png;base64,AAAA', + 'data:image/jpeg;name=b.jpg;base64,BBBB', + ], + }, + ]); + const field = new ActionFieldFileList('attachments', fieldFormStates); + httpRequester.query.mockResolvedValue({ + fields: [ + { + field: 'attachments', + type: 'FileList', + isRequired: false, + isReadOnly: false, + value: ['data:image/jpeg;name=b.jpg;base64,BBBB'], + }, + ], + layout: [], + }); + + await field.remove('a.png'); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + attributes: expect.objectContaining({ + fields: [ + expect.objectContaining({ + field: 'attachments', + value: ['data:image/jpeg;name=b.jpg;base64,BBBB'], + }), + ], + }), + }), + }), + }), + ); + }); + + it('should only remove the exact file when one name is a prefix of another', async () => { + await setupFields([ + { + field: 'attachments', + type: 'FileList', + isRequired: false, + isReadOnly: false, + value: [ + 'data:text/plain;name=report.txt;base64,AAAA', + 'data:text/plain;name=my-report.txt;base64,BBBB', + ], + }, + ]); + const field = new ActionFieldFileList('attachments', fieldFormStates); + httpRequester.query.mockResolvedValue({ + fields: [ + { + field: 'attachments', + type: 'FileList', + isRequired: false, + isReadOnly: false, + value: ['data:text/plain;name=my-report.txt;base64,BBBB'], + }, + ], + layout: [], + }); + + await field.remove('report.txt'); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ + attributes: expect.objectContaining({ + fields: [ + expect.objectContaining({ + field: 'attachments', + value: ['data:text/plain;name=my-report.txt;base64,BBBB'], + }), + ], + }), + }), + }), + }), + ); + }); + + it('should throw error when removing a non-existent file', async () => { + await setupFields([ + { + field: 'attachments', + type: 'FileList', + isRequired: false, + isReadOnly: false, + value: ['data:image/png;name=a.png;base64,AAAA'], + }, + ]); + const field = new ActionFieldFileList('attachments', fieldFormStates); + + await expect(field.remove('unknown.png')).rejects.toThrow( + 'File "unknown.png" is not in the list', + ); + }); + }); + describe('ActionField base class methods', () => { beforeEach(async () => { await setupFields([ diff --git a/packages/agent-client/test/domains/action.test.ts b/packages/agent-client/test/domains/action.test.ts index 341d429d4c..9e647e34e9 100644 --- a/packages/agent-client/test/domains/action.test.ts +++ b/packages/agent-client/test/domains/action.test.ts @@ -270,6 +270,36 @@ describe('Action', () => { expect(field).toBeDefined(); }); + it('should return file field for File type', () => { + fieldsFormStates.getField.mockReturnValue({ + getName: () => 'document', + getType: () => 'File', + } as any); + + const field = action.getField('document'); + expect(field).toBeDefined(); + }); + + it('should return file list field for FileList type', () => { + fieldsFormStates.getField.mockReturnValue({ + getName: () => 'attachments', + getType: () => 'FileList', + } as any); + + const field = action.getField('attachments'); + expect(field).toBeDefined(); + }); + + it('should return file list field for ["File"] type', () => { + fieldsFormStates.getField.mockReturnValue({ + getName: () => 'attachments', + getType: () => ['File'], + } as any); + + const field = action.getField('attachments'); + expect(field).toBeDefined(); + }); + it('should return string field as default', () => { fieldsFormStates.getField.mockReturnValue({ getName: () => 'unknown', @@ -341,6 +371,16 @@ describe('Action', () => { const field = action.getRadioGroupField('choice'); expect(field).toBeDefined(); }); + + it('should return ActionFieldFile', () => { + const field = action.getFileField('document'); + expect(field).toBeDefined(); + }); + + it('should return ActionFieldFileList', () => { + const field = action.getFileListField('attachments'); + expect(field).toBeDefined(); + }); }); describe('getLayout', () => { diff --git a/packages/agent-client/test/integration/remote-agent-client.integration.test.ts b/packages/agent-client/test/integration/remote-agent-client.integration.test.ts index ed62b3feb1..a77ff933eb 100644 --- a/packages/agent-client/test/integration/remote-agent-client.integration.test.ts +++ b/packages/agent-client/test/integration/remote-agent-client.integration.test.ts @@ -147,6 +147,44 @@ describe('RemoteAgentClient Integration', () => { return; } + // Upload file action - load + if (req.method === 'POST' && pathname === '/forest/actions/upload-file/hooks/load') { + res.statusCode = 200; + res.end( + JSON.stringify({ + fields: [ + { field: 'document', type: 'File', value: null }, + { field: 'attachments', type: 'FileList', value: null }, + ], + layout: [], + }), + ); + + return; + } + + // Upload file action - change hook + if (req.method === 'POST' && pathname === '/forest/actions/upload-file/hooks/change') { + const parsedBody = JSON.parse(body); + res.statusCode = 200; + res.end( + JSON.stringify({ + fields: parsedBody.data.attributes.fields, + layout: [], + }), + ); + + return; + } + + // Upload file action - execute + if (req.method === 'POST' && pathname === '/forest/actions/upload-file') { + res.statusCode = 200; + res.end(JSON.stringify({ success: 'Files uploaded successfully' })); + + return; + } + // Default 404 res.statusCode = 404; res.end(JSON.stringify({ error: 'Not found' })); @@ -381,4 +419,116 @@ describe('RemoteAgentClient Integration', () => { expect(requestLog[0].headers['content-type']).toBe('application/json'); }); }); + + describe('File action fields', () => { + const createClient = () => + createRemoteAgentClient({ + url: `http://localhost:${serverPort}`, + token: 'test-token', + actionEndpoints: { + users: { + 'upload-file': { + name: 'upload-file', + endpoint: '/forest/actions/upload-file', + }, + }, + }, + }); + + it('should fill a File field and execute the action with the correct data URI', async () => { + const client = createClient(); + const action = await client.collection('users').action('upload-file', { recordId: '1' }); + + const fileField = action.getFileField('document'); + await fileField.fill({ + mimeType: 'application/pdf', + buffer: Buffer.from('pdf-content'), + name: 'report.pdf', + }); + + const result = await action.execute(); + + expect(result).toEqual({ success: 'Files uploaded successfully' }); + + const executeRequest = requestLog[requestLog.length - 1]; + const executeBody = JSON.parse(executeRequest.body); + const expectedUri = `data:application/pdf;name=report.pdf;base64,${Buffer.from( + 'pdf-content', + ).toString('base64')}`; + expect(executeBody.data.attributes.values.document).toBe(expectedUri); + }); + + it('should add multiple files to a FileList field and execute the action', async () => { + const client = createClient(); + const action = await client.collection('users').action('upload-file', { recordId: '1' }); + + const fileListField = action.getFileListField('attachments'); + await fileListField.add({ + mimeType: 'image/png', + buffer: Buffer.from('img1'), + name: 'photo.png', + }); + await fileListField.add({ + mimeType: 'image/jpeg', + buffer: Buffer.from('img2'), + name: 'avatar.jpg', + }); + + const result = await action.execute(); + + expect(result).toEqual({ success: 'Files uploaded successfully' }); + + const executeRequest = requestLog[requestLog.length - 1]; + const executeBody = JSON.parse(executeRequest.body); + const { attachments } = executeBody.data.attributes.values; + expect(attachments).toHaveLength(2); + expect(attachments[0]).toBe( + `data:image/png;name=photo.png;base64,${Buffer.from('img1').toString('base64')}`, + ); + expect(attachments[1]).toBe( + `data:image/jpeg;name=avatar.jpg;base64,${Buffer.from('img2').toString('base64')}`, + ); + }); + + it('should add and remove files from a FileList field', async () => { + const client = createClient(); + const action = await client.collection('users').action('upload-file', { recordId: '1' }); + + const fileListField = action.getFileListField('attachments'); + await fileListField.add({ + mimeType: 'image/png', + buffer: Buffer.from('img1'), + name: 'photo.png', + }); + await fileListField.add({ + mimeType: 'image/jpeg', + buffer: Buffer.from('img2'), + name: 'avatar.jpg', + }); + await fileListField.remove('photo.png'); + + const result = await action.execute(); + + expect(result).toEqual({ success: 'Files uploaded successfully' }); + + const executeRequest = requestLog[requestLog.length - 1]; + const executeBody = JSON.parse(executeRequest.body); + const { attachments } = executeBody.data.attributes.values; + expect(attachments).toHaveLength(1); + expect(attachments[0]).toBe( + `data:image/jpeg;name=avatar.jpg;base64,${Buffer.from('img2').toString('base64')}`, + ); + }); + + it('should route File and FileList types correctly via getField', async () => { + const client = createClient(); + const action = await client.collection('users').action('upload-file', { recordId: '1' }); + + const docField = action.getField('document'); + const attachField = action.getField('attachments'); + + expect(docField.getType()).toBe('File'); + expect(attachField.getType()).toBe('FileList'); + }); + }); }); diff --git a/packages/agent-testing/test/action-file.test.ts b/packages/agent-testing/test/action-file.test.ts new file mode 100644 index 0000000000..5c7c0ad5f0 --- /dev/null +++ b/packages/agent-testing/test/action-file.test.ts @@ -0,0 +1,177 @@ +import type { TestableAgent } from '../src'; +import type { Agent } from '@forestadmin/agent'; + +import { buildSequelizeInstance, createSqlDataSource } from '@forestadmin/datasource-sql'; +import { DataTypes } from 'sequelize'; + +import { STORAGE_PREFIX, logger } from '../example/utils'; +import { createTestableAgent } from '../src'; + +describe('action with File fields', () => { + let testableAgent: TestableAgent; + let sequelize: Awaited>; + let documentId: number; + const storage = `${STORAGE_PREFIX}-action-file-test.db`; + + const actionFormCustomizer = (agent: Agent) => { + agent.customizeCollection('documents', collection => { + collection.addAction('Upload files', { + scope: 'Single', + form: [ + { label: 'Main file', type: 'File' }, + { label: 'Attachments', type: 'FileList' }, + ], + execute: async context => { + const mainFile = context.formValues['Main file']; + const attachments = context.formValues.Attachments; + + const { id } = await context.getRecord(['id']); + await context.dataSource.getCollection('documents').update( + { conditionTree: { field: 'id', operator: 'Equal', value: id } }, + { + fileName: mainFile?.name, + fileMimeType: mainFile?.mimeType, + fileContent: mainFile?.buffer?.toString('base64'), + attachmentCount: attachments?.length ?? 0, + }, + ); + }, + }); + }); + }; + + const createTable = async () => { + sequelize = await buildSequelizeInstance({ dialect: 'sqlite', storage }, logger); + + sequelize.define( + 'documents', + { + title: { type: DataTypes.STRING }, + fileName: { type: DataTypes.STRING }, + fileMimeType: { type: DataTypes.STRING }, + fileContent: { type: DataTypes.STRING }, + attachmentCount: { type: DataTypes.INTEGER }, + }, + { tableName: 'documents' }, + ); + await sequelize.sync({ force: true }); + }; + + beforeAll(async () => { + await createTable(); + testableAgent = await createTestableAgent((agent: Agent) => { + agent.addDataSource(createSqlDataSource({ dialect: 'sqlite', storage })); + actionFormCustomizer(agent); + }); + await testableAgent.start(); + }); + + afterAll(async () => { + await testableAgent?.stop(); + await sequelize?.close(); + }); + + beforeEach(async () => { + const created = await sequelize.models.documents.create({ + title: 'My document', + fileName: null, + fileMimeType: null, + attachmentCount: 0, + }); + documentId = created.dataValues.id; + }); + + it('should fill a File field and execute the action', async () => { + const action = await testableAgent + .collection('documents') + .action('Upload files', { recordId: documentId }); + + const fileField = action.getFileField('Main file'); + await fileField.fill({ + mimeType: 'application/pdf', + buffer: Buffer.from('pdf-content'), + name: 'report.pdf', + }); + + const expectedUri = `data:application/pdf;name=report.pdf;base64,${Buffer.from( + 'pdf-content', + ).toString('base64')}`; + expect(fileField.getValue()).toBe(expectedUri); + + await action.execute(); + + const [doc] = await testableAgent + .collection('documents') + .list<{ fileName: string; fileMimeType: string; fileContent: string }>({ + filters: { field: 'id', value: documentId, operator: 'Equal' }, + }); + + expect(doc.fileName).toBe('report.pdf'); + expect(doc.fileMimeType).toBe('application/pdf'); + expect(Buffer.from(doc.fileContent, 'base64').toString()).toBe('pdf-content'); + }); + + it('should add and remove files from a FileList field', async () => { + const action = await testableAgent + .collection('documents') + .action('Upload files', { recordId: documentId }); + + const fileListField = action.getFileListField('Attachments'); + await fileListField.add({ + mimeType: 'image/png', + buffer: Buffer.from('img1'), + name: 'photo.png', + }); + await fileListField.add({ + mimeType: 'image/jpeg', + buffer: Buffer.from('img2'), + name: 'avatar.jpg', + }); + + const values = fileListField.getValue() as string[]; + expect(values).toHaveLength(2); + + await fileListField.remove('photo.png'); + + const updatedValues = fileListField.getValue() as string[]; + expect(updatedValues).toHaveLength(1); + expect(updatedValues[0]).toContain('name=avatar.jpg'); + + await action.execute(); + + const [doc] = await testableAgent.collection('documents').list<{ attachmentCount: number }>({ + filters: { field: 'id', value: documentId, operator: 'Equal' }, + }); + + expect(doc.attachmentCount).toBe(1); + }); + + it('should clear a File field by filling with null', async () => { + const action = await testableAgent + .collection('documents') + .action('Upload files', { recordId: documentId }); + + const fileField = action.getFileField('Main file'); + await fileField.fill({ + mimeType: 'text/plain', + buffer: Buffer.from('hello'), + name: 'test.txt', + }); + expect(fileField.getValue()).toBeTruthy(); + + await fileField.fill(null); + expect(fileField.getValue()).toBeNull(); + }); + + it('should route File/FileList types correctly via getField', async () => { + const action = await testableAgent + .collection('documents') + .action('Upload files', { recordId: documentId }); + + const mainFileField = action.getField('Main file'); + const attachmentsField = action.getField('Attachments'); + + expect(mainFileField.getType()).toBe('File'); + expect(attachmentsField.getType()).toEqual(['File']); + }); +});