From 69c48b1a2b599644080eaef5036fcf831f87e877 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 16 Feb 2026 10:52:41 +0100 Subject: [PATCH 1/6] feat(agent-client): add File and FileList action field support Add ActionFieldFile and ActionFieldFileList classes so agent-testing users can programmatically fill file fields in action forms. File values are serialized as Data URIs matching the existing agent convention. Co-Authored-By: Claude Opus 4.6 --- .../action-fields/action-field-file-list.ts | 19 ++ .../src/action-fields/action-field-file.ts | 29 ++ packages/agent-client/src/domains/action.ts | 15 + .../test/action-fields/action-fields.test.ts | 307 ++++++++++++++++++ .../agent-client/test/domains/action.test.ts | 40 +++ 5 files changed, 410 insertions(+) create mode 100644 packages/agent-client/src/action-fields/action-field-file-list.ts create mode 100644 packages/agent-client/src/action-fields/action-field-file.ts 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..1cf68454fd --- /dev/null +++ b/packages/agent-client/src/action-fields/action-field-file-list.ts @@ -0,0 +1,19 @@ +import ActionFieldFile from './action-field-file'; + +export default class ActionFieldFileList extends ActionFieldFile { + async addFile(file: { mimeType: string; buffer: Buffer; name: string; charset?: string }) { + const values = (this.field?.getValue() as string[]) || []; + await this.setValue([...values, ActionFieldFileList.makeDataUri(file)]); + } + + async removeFile(fileName: string) { + const values = (this.field?.getValue() as string[]) || []; + const filtered = values.filter(uri => !uri.includes(`name=${encodeURIComponent(fileName)}`)); + + 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..8eb28b2da3 --- /dev/null +++ b/packages/agent-client/src/action-fields/action-field-file.ts @@ -0,0 +1,29 @@ +import ActionField from './action-field'; + +export default class ActionFieldFile extends ActionField { + async fill(file?: { mimeType: string; buffer: Buffer; name: string; charset?: string }) { + if (this.isValueUndefinedOrNull(file)) { + await this.setValue(file); + + return; + } + + await this.setValue(ActionFieldFile.makeDataUri(file)); + } + + protected static makeDataUri(file: { + mimeType: string; + buffer: Buffer; + name: string; + charset?: string; + }): string { + const { mimeType, buffer, ...rest } = file; + const mediaTypes = Object.entries(rest) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join(';'); + + return mediaTypes.length + ? `data:${mimeType};${mediaTypes};base64,${buffer.toString('base64')}` + : `data:${mimeType};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..ccd497c11b 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,311 @@ 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.addFile({ 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.addFile({ 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.removeFile('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 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.removeFile('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', () => { From 57d6af00553bc202cea6d0501d1c949c617e428f Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 16 Feb 2026 11:02:46 +0100 Subject: [PATCH 2/6] fix(agent-client): address review issues on File/FileList fields - Fix removeFile substring matching bug: use exact parameter matching on metadata section instead of uri.includes() to avoid false positives when one filename is a prefix of another - Fix ActionFieldFileList inheritance: extend ActionField directly (matching StringList/NumberList convention) instead of ActionFieldFile, preventing inherited fill() from setting a scalar on an array field - Fix fill() signature: accept null in addition to undefined - Extract shared FileInput type (DRY) - Remove unreachable branch in makeDataUri (name is always present) - Make makeDataUri public static (needed by FileList via composition) - Add integration tests for full File/FileList action flow - Add unit test for substring-name collision in removeFile Co-Authored-By: Claude Opus 4.6 --- .../action-fields/action-field-file-list.ts | 16 +- .../src/action-fields/action-field-file.ts | 17 +- .../test/action-fields/action-fields.test.ts | 47 ++++++ .../remote-agent-client.integration.test.ts | 150 ++++++++++++++++++ 4 files changed, 214 insertions(+), 16 deletions(-) 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 index 1cf68454fd..a197bc0b57 100644 --- a/packages/agent-client/src/action-fields/action-field-file-list.ts +++ b/packages/agent-client/src/action-fields/action-field-file-list.ts @@ -1,14 +1,20 @@ -import ActionFieldFile from './action-field-file'; +import ActionField from './action-field'; +import ActionFieldFile, { type FileInput } from './action-field-file'; -export default class ActionFieldFileList extends ActionFieldFile { - async addFile(file: { mimeType: string; buffer: Buffer; name: string; charset?: string }) { +export default class ActionFieldFileList extends ActionField { + async addFile(file: FileInput) { const values = (this.field?.getValue() as string[]) || []; - await this.setValue([...values, ActionFieldFileList.makeDataUri(file)]); + await this.setValue([...values, ActionFieldFile.makeDataUri(file)]); } async removeFile(fileName: string) { const values = (this.field?.getValue() as string[]) || []; - const filtered = values.filter(uri => !uri.includes(`name=${encodeURIComponent(fileName)}`)); + 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`); diff --git a/packages/agent-client/src/action-fields/action-field-file.ts b/packages/agent-client/src/action-fields/action-field-file.ts index 8eb28b2da3..5c4020c823 100644 --- a/packages/agent-client/src/action-fields/action-field-file.ts +++ b/packages/agent-client/src/action-fields/action-field-file.ts @@ -1,7 +1,9 @@ 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?: { mimeType: string; buffer: Buffer; name: string; charset?: string }) { + async fill(file?: FileInput | null) { if (this.isValueUndefinedOrNull(file)) { await this.setValue(file); @@ -11,19 +13,12 @@ export default class ActionFieldFile extends ActionField { await this.setValue(ActionFieldFile.makeDataUri(file)); } - protected static makeDataUri(file: { - mimeType: string; - buffer: Buffer; - name: string; - charset?: string; - }): string { + static makeDataUri(file: FileInput): string { const { mimeType, buffer, ...rest } = file; - const mediaTypes = Object.entries(rest) + const params = Object.entries(rest) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join(';'); - return mediaTypes.length - ? `data:${mimeType};${mediaTypes};base64,${buffer.toString('base64')}` - : `data:${mimeType};base64,${buffer.toString('base64')}`; + return `data:${mimeType};${params};base64,${buffer.toString('base64')}`; } } 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 ccd497c11b..eec0c3704a 100644 --- a/packages/agent-client/test/action-fields/action-fields.test.ts +++ b/packages/agent-client/test/action-fields/action-fields.test.ts @@ -1094,6 +1094,53 @@ describe('ActionField implementations', () => { ); }); + 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.removeFile('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([ { 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..f51f6f03fd 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.addFile({ + mimeType: 'image/png', + buffer: Buffer.from('img1'), + name: 'photo.png', + }); + await fileListField.addFile({ + 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.addFile({ + mimeType: 'image/png', + buffer: Buffer.from('img1'), + name: 'photo.png', + }); + await fileListField.addFile({ + mimeType: 'image/jpeg', + buffer: Buffer.from('img2'), + name: 'avatar.jpg', + }); + await fileListField.removeFile('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'); + }); + }); }); From 6046c274c93d812d1c3940574c70295165617929 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 16 Feb 2026 11:06:04 +0100 Subject: [PATCH 3/6] test(agent-testing): add example tests for File and FileList action fields Add end-to-end example demonstrating File/FileList field usage: - Fill a File field and verify the action receives parsed file data - Add/remove files from a FileList field - Clear a File field with null - Verify getField type routing for File/FileList Co-Authored-By: Claude Opus 4.6 --- .../example/test/add-action-file.test.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 packages/agent-testing/example/test/add-action-file.test.ts diff --git a/packages/agent-testing/example/test/add-action-file.test.ts b/packages/agent-testing/example/test/add-action-file.test.ts new file mode 100644 index 0000000000..b099268054 --- /dev/null +++ b/packages/agent-testing/example/test/add-action-file.test.ts @@ -0,0 +1,173 @@ +import { Agent } from '@forestadmin/agent'; +import { buildSequelizeInstance, createSqlDataSource } from '@forestadmin/datasource-sql'; +import { DataTypes } from 'sequelize'; + +import { createTestableAgent, TestableAgent } from '../../src'; +import { STORAGE_PREFIX, logger } from '../utils'; + +describe('addAction with File fields', () => { + let testableAgent: TestableAgent; + let sequelize: Awaited>; + let documentId: number; + const storage = `${STORAGE_PREFIX}-action-file.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, + 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 }, + 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', + }); + + expect(fileField.getValue()).toBe( + `data:application/pdf;name=report.pdf;base64,${Buffer.from('pdf-content').toString('base64')}`, + ); + + await action.execute(); + + const [doc] = await testableAgent + .collection('documents') + .list<{ fileName: string; fileMimeType: string }>({ + filters: { field: 'id', value: documentId, operator: 'Equal' }, + }); + + expect(doc.fileName).toBe('report.pdf'); + expect(doc.fileMimeType).toBe('application/pdf'); + }); + + 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.addFile({ + mimeType: 'image/png', + buffer: Buffer.from('img1'), + name: 'photo.png', + }); + await fileListField.addFile({ + mimeType: 'image/jpeg', + buffer: Buffer.from('img2'), + name: 'avatar.jpg', + }); + + const values = fileListField.getValue() as string[]; + expect(values).toHaveLength(2); + + await fileListField.removeFile('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']); + }); +}); From 39bb4dae544a59d26762cbc618a5f69648d7463b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 16 Feb 2026 11:26:22 +0100 Subject: [PATCH 4/6] test(agent-testing): move File/FileList tests to test/ folder Move the example tests from example/test/ to test/ since the example folder will be removed. Co-Authored-By: Claude Opus 4.6 --- .../action-file.test.ts} | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) rename packages/agent-testing/{example/test/add-action-file.test.ts => test/action-file.test.ts} (87%) diff --git a/packages/agent-testing/example/test/add-action-file.test.ts b/packages/agent-testing/test/action-file.test.ts similarity index 87% rename from packages/agent-testing/example/test/add-action-file.test.ts rename to packages/agent-testing/test/action-file.test.ts index b099268054..ed9e763463 100644 --- a/packages/agent-testing/example/test/add-action-file.test.ts +++ b/packages/agent-testing/test/action-file.test.ts @@ -1,15 +1,17 @@ -import { Agent } from '@forestadmin/agent'; +import type { TestableAgent } from '../src'; +import type { Agent } from '@forestadmin/agent'; + import { buildSequelizeInstance, createSqlDataSource } from '@forestadmin/datasource-sql'; import { DataTypes } from 'sequelize'; -import { createTestableAgent, TestableAgent } from '../../src'; -import { STORAGE_PREFIX, logger } from '../utils'; +import { STORAGE_PREFIX, logger } from '../example/utils'; +import { createTestableAgent } from '../src'; -describe('addAction with File fields', () => { +describe('action with File fields', () => { let testableAgent: TestableAgent; let sequelize: Awaited>; let documentId: number; - const storage = `${STORAGE_PREFIX}-action-file.db`; + const storage = `${STORAGE_PREFIX}-action-file-test.db`; const actionFormCustomizer = (agent: Agent) => { agent.customizeCollection('documents', collection => { @@ -89,9 +91,10 @@ describe('addAction with File fields', () => { name: 'report.pdf', }); - expect(fileField.getValue()).toBe( - `data:application/pdf;name=report.pdf;base64,${Buffer.from('pdf-content').toString('base64')}`, - ); + const expectedUri = `data:application/pdf;name=report.pdf;base64,${Buffer.from( + 'pdf-content', + ).toString('base64')}`; + expect(fileField.getValue()).toBe(expectedUri); await action.execute(); @@ -133,11 +136,9 @@ describe('addAction with File fields', () => { await action.execute(); - const [doc] = await testableAgent - .collection('documents') - .list<{ attachmentCount: number }>({ - filters: { field: 'id', value: documentId, operator: 'Equal' }, - }); + const [doc] = await testableAgent.collection('documents').list<{ attachmentCount: number }>({ + filters: { field: 'id', value: documentId, operator: 'Equal' }, + }); expect(doc.attachmentCount).toBe(1); }); From c831d70112d194b4ec93b69349bcd8241f7ba6ed Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 16 Feb 2026 12:37:40 +0100 Subject: [PATCH 5/6] test(agent-testing): verify file buffer survives the full round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store and assert the base64 content in the execute handler to ensure the buffer is correctly preserved through encode → transport → decode. Co-Authored-By: Claude Opus 4.6 --- packages/agent-testing/test/action-file.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/agent-testing/test/action-file.test.ts b/packages/agent-testing/test/action-file.test.ts index ed9e763463..dd5849042c 100644 --- a/packages/agent-testing/test/action-file.test.ts +++ b/packages/agent-testing/test/action-file.test.ts @@ -31,6 +31,7 @@ describe('action with File fields', () => { { fileName: mainFile?.name, fileMimeType: mainFile?.mimeType, + fileContent: mainFile?.buffer?.toString('base64'), attachmentCount: attachments?.length ?? 0, }, ); @@ -48,6 +49,7 @@ describe('action with File fields', () => { title: { type: DataTypes.STRING }, fileName: { type: DataTypes.STRING }, fileMimeType: { type: DataTypes.STRING }, + fileContent: { type: DataTypes.STRING }, attachmentCount: { type: DataTypes.INTEGER }, }, { tableName: 'documents' }, @@ -100,12 +102,13 @@ describe('action with File fields', () => { const [doc] = await testableAgent .collection('documents') - .list<{ fileName: string; fileMimeType: string }>({ + .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 () => { From 7b69bb9889bcf666d09553d570cdefbb7820452e Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 16 Feb 2026 14:19:17 +0100 Subject: [PATCH 6/6] refactor(agent-client): rename addFile/removeFile to add/remove for API consistency Align FileList field methods with StringList and NumberList conventions. Co-Authored-By: Claude Opus 4.6 --- .../src/action-fields/action-field-file-list.ts | 4 ++-- .../test/action-fields/action-fields.test.ts | 10 +++++----- .../remote-agent-client.integration.test.ts | 10 +++++----- packages/agent-testing/test/action-file.test.ts | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) 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 index a197bc0b57..b42344ded3 100644 --- a/packages/agent-client/src/action-fields/action-field-file-list.ts +++ b/packages/agent-client/src/action-fields/action-field-file-list.ts @@ -2,12 +2,12 @@ import ActionField from './action-field'; import ActionFieldFile, { type FileInput } from './action-field-file'; export default class ActionFieldFileList extends ActionField { - async addFile(file: FileInput) { + async add(file: FileInput) { const values = (this.field?.getValue() as string[]) || []; await this.setValue([...values, ActionFieldFile.makeDataUri(file)]); } - async removeFile(fileName: string) { + async remove(fileName: string) { const values = (this.field?.getValue() as string[]) || []; const nameParam = `name=${encodeURIComponent(fileName)}`; const filtered = values.filter(uri => { 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 eec0c3704a..494a5dd190 100644 --- a/packages/agent-client/test/action-fields/action-fields.test.ts +++ b/packages/agent-client/test/action-fields/action-fields.test.ts @@ -980,7 +980,7 @@ describe('ActionField implementations', () => { layout: [], }); - await field.addFile({ mimeType: 'image/jpeg', buffer: Buffer.from('b'), name: 'b.jpg' }); + await field.add({ mimeType: 'image/jpeg', buffer: Buffer.from('b'), name: 'b.jpg' }); expect(httpRequester.query).toHaveBeenCalledWith( expect.objectContaining({ @@ -1027,7 +1027,7 @@ describe('ActionField implementations', () => { layout: [], }); - await field.addFile({ mimeType: 'image/png', buffer: Buffer.from('test'), name: 'a.png' }); + await field.add({ mimeType: 'image/png', buffer: Buffer.from('test'), name: 'a.png' }); expect(httpRequester.query).toHaveBeenCalledWith( expect.objectContaining({ @@ -1074,7 +1074,7 @@ describe('ActionField implementations', () => { layout: [], }); - await field.removeFile('a.png'); + await field.remove('a.png'); expect(httpRequester.query).toHaveBeenCalledWith( expect.objectContaining({ @@ -1121,7 +1121,7 @@ describe('ActionField implementations', () => { layout: [], }); - await field.removeFile('report.txt'); + await field.remove('report.txt'); expect(httpRequester.query).toHaveBeenCalledWith( expect.objectContaining({ @@ -1153,7 +1153,7 @@ describe('ActionField implementations', () => { ]); const field = new ActionFieldFileList('attachments', fieldFormStates); - await expect(field.removeFile('unknown.png')).rejects.toThrow( + await expect(field.remove('unknown.png')).rejects.toThrow( 'File "unknown.png" is not in the list', ); }); 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 f51f6f03fd..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 @@ -463,12 +463,12 @@ describe('RemoteAgentClient Integration', () => { const action = await client.collection('users').action('upload-file', { recordId: '1' }); const fileListField = action.getFileListField('attachments'); - await fileListField.addFile({ + await fileListField.add({ mimeType: 'image/png', buffer: Buffer.from('img1'), name: 'photo.png', }); - await fileListField.addFile({ + await fileListField.add({ mimeType: 'image/jpeg', buffer: Buffer.from('img2'), name: 'avatar.jpg', @@ -495,17 +495,17 @@ describe('RemoteAgentClient Integration', () => { const action = await client.collection('users').action('upload-file', { recordId: '1' }); const fileListField = action.getFileListField('attachments'); - await fileListField.addFile({ + await fileListField.add({ mimeType: 'image/png', buffer: Buffer.from('img1'), name: 'photo.png', }); - await fileListField.addFile({ + await fileListField.add({ mimeType: 'image/jpeg', buffer: Buffer.from('img2'), name: 'avatar.jpg', }); - await fileListField.removeFile('photo.png'); + await fileListField.remove('photo.png'); const result = await action.execute(); diff --git a/packages/agent-testing/test/action-file.test.ts b/packages/agent-testing/test/action-file.test.ts index dd5849042c..5c7c0ad5f0 100644 --- a/packages/agent-testing/test/action-file.test.ts +++ b/packages/agent-testing/test/action-file.test.ts @@ -117,12 +117,12 @@ describe('action with File fields', () => { .action('Upload files', { recordId: documentId }); const fileListField = action.getFileListField('Attachments'); - await fileListField.addFile({ + await fileListField.add({ mimeType: 'image/png', buffer: Buffer.from('img1'), name: 'photo.png', }); - await fileListField.addFile({ + await fileListField.add({ mimeType: 'image/jpeg', buffer: Buffer.from('img2'), name: 'avatar.jpg', @@ -131,7 +131,7 @@ describe('action with File fields', () => { const values = fileListField.getValue() as string[]; expect(values).toHaveLength(2); - await fileListField.removeFile('photo.png'); + await fileListField.remove('photo.png'); const updatedValues = fileListField.getValue() as string[]; expect(updatedValues).toHaveLength(1);