Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
34814bb
feat(agent testing): introduce Agent testing
Dec 18, 2025
f9e5389
fix: fix linter issues
Jan 5, 2026
e38c773
feat: add README
Jan 5, 2026
53d692e
fix: remove duplicated files
Jan 5, 2026
8ceef43
fix: fix the tests
Jan 6, 2026
8611a83
fix(example): add explicit datasource-customizer dependency
Jan 7, 2026
378f789
fix: add resolutions for forestadmin packages
Jan 7, 2026
19b8dc4
fix: add forestadmin-client to resolutions
Jan 7, 2026
798930d
chore: force CI cache invalidation
Jan 7, 2026
7bce5b6
fix(example): add explicit forestadmin-client dependency
Jan 7, 2026
c862720
fix(agent-testing): cast ForestAdminClientMock to any
Jan 7, 2026
205351b
refactor(agent-testing): simplify structure and use agent-client
Jan 13, 2026
49f79de
chore: revert unrelated changes in _example and mcp-server
Jan 13, 2026
4cf5fac
chore: revert unrelated changes in root package.json
Jan 13, 2026
ae336a4
fix(agent-testing): use fields instead of projection and fix deps
Jan 14, 2026
360a242
chore(agent-testing): use explicit versions and remove placeholder tests
Jan 14, 2026
c524b50
docs(agent-client): add comments to explain CSV export query params
Jan 14, 2026
27e119f
chore(agent-testing): reset changelog
Jan 19, 2026
852d790
docs(agent-testing): recommend sandbox mode as primary testing approach
Jan 19, 2026
e983389
docs(agent-testing): remove deprecated createTestableAgent mode
Jan 19, 2026
3343cab
refactor(agent-testing): rename createForestAgentClient to createAgen…
Jan 19, 2026
2ea3290
ci: add agent-testing to test matrix
Jan 19, 2026
09ce8e8
fix(agent-testing): update dependency versions
Jan 19, 2026
23eeb91
docs(agent-testing): remove permissions example from Quick Start
Jan 19, 2026
ac52d5e
docs(agent-testing): simplify README with clear setup
Jan 19, 2026
9d689af
docs(agent-testing): minimal README focused on setup
Jan 19, 2026
9417d25
docs(agent-testing): improve README clarity and UX
Jan 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jobs:
package:
- agent
- agent-client
- agent-testing
- forest-cloud
- mcp-server
- datasource-customizer
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-client/src/domains/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@ export default class Collection extends CollectionChart {
path: `/forest/${this.name}.csv`,
contentType: 'text/csv',
query: {
// QuerySerializer.serialize sends fields[collection]=... to filter which fields are returned
...QuerySerializer.serialize(options, this.name),
...{ header: JSON.stringify(options?.fields) },
// header is used separately by the CSV generator to build the first row of the CSV file
...(options?.fields && { header: JSON.stringify(options.fields) }),
},
stream,
});
Expand Down
17 changes: 15 additions & 2 deletions packages/agent-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import type { ActionEndpointsByCollection } from './domains/action';
import type { PermissionsOverride } from './domains/remote-agent-client';
import type {
CollectionPermissionsOverride,
PermissionsOverride,
SmartActionPermissionsOverride,
} from './domains/remote-agent-client';

import ActionFieldJson from './action-fields/action-field-json';
import ActionFieldStringList from './action-fields/action-field-string-list';
import RemoteAgentClient from './domains/remote-agent-client';
import HttpRequester from './http-requester';

// eslint-disable-next-line import/prefer-default-export
export { ActionFieldJson, ActionFieldStringList, RemoteAgentClient, HttpRequester };
export type {
ActionEndpointsByCollection,
CollectionPermissionsOverride,
PermissionsOverride,
SmartActionPermissionsOverride,
};

export function createRemoteAgentClient(params: {
overridePermissions?: (permissions: PermissionsOverride) => Promise<void>;
actionEndpoints?: ActionEndpointsByCollection;
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-testing/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
database-test*
schema-test.json
typings-test.ts
3 changes: 3 additions & 0 deletions packages/agent-testing/.releaserc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const packageName = __dirname.split('/').pop();

module.exports = { ...require('../../.releaserc.js'), tagFormat: packageName + '@${version}' }
1 change: 1 addition & 0 deletions packages/agent-testing/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
674 changes: 674 additions & 0 deletions packages/agent-testing/LICENSE

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions packages/agent-testing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# @forestadmin/agent-testing

Test your Forest Admin agent customizations (actions, hooks, segments) locally without connecting to Forest Admin servers.

## Installation

```bash
npm install --save-dev @forestadmin/agent-testing
```

## Quick Start

```typescript
import {
createAgentTestClient,
createForestServerSandbox,
} from '@forestadmin/agent-testing';

describe('My Agent', () => {
let sandbox, client;

beforeAll(async () => {
// 1. Start a local sandbox that replaces Forest Admin servers
sandbox = await createForestServerSandbox(3001);

// 2. Start your agent pointing to the sandbox
// FOREST_SERVER_URL=http://localhost:3001 node your-agent.js

// 3. Connect the test client
client = await createAgentTestClient({
serverUrl: 'http://localhost:3001',
agentUrl: 'http://localhost:3310',
agentSchemaPath: './.forestadmin-schema.json',
agentForestEnvSecret: process.env.FOREST_ENV_SECRET,
agentForestAuthSecret: process.env.FOREST_AUTH_SECRET,
});
});

afterAll(async () => {
await sandbox?.close();
});

it('should list users', async () => {
const users = await client.collection('users').list();
expect(users.length).toBeGreaterThan(0);
});
});
```

## License

GPL-3.0
4 changes: 4 additions & 0 deletions packages/agent-testing/example/sandbox-server-example.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const { createForestServerSandbox } = require('../dist/index');

// Example of creating a sandbox server on port 3456
createForestServerSandbox(3456);
276 changes: 276 additions & 0 deletions packages/agent-testing/example/test/add-action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
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', () => {
let testableAgent: TestableAgent;
let sequelize: Awaited<ReturnType<typeof buildSequelizeInstance>>;
let restaurantId: number;
const storage = `${STORAGE_PREFIX}-action.db`;

const actionFormCustomizer = (agent: Agent) => {
agent.customizeCollection('restaurants', collection => {
collection.addAction('Leave a review', {
scope: 'Single',
form: [
{
type: 'Layout',
component: 'Page',
nextButtonLabel: 'Next',
previousButtonLabel: 'Back',
elements: [
{
component: 'Separator',
type: 'Layout',
},
{ label: 'Metadata', type: 'Json', widget: 'JsonEditor' },
{ component: 'HtmlBlock', content: '<h1>Welcome</h1>', type: 'Layout' },
{ label: 'rating', type: 'Number', isRequired: true },
{
label: 'Put a comment',
type: 'String',
// Only display this field if the rating is >= 4
if: context => Number(context.formValues.rating) >= 4,
},
{
label: 'Would you recommend us?',
type: 'String',
widget: 'RadioGroup',
options: [
{ value: 'yes', label: 'Yes, absolutely!' },
{ value: 'no', label: 'Not really...' },
],
defaultValue: 'yes',
},
{
label: 'Why do you like us?',
type: 'StringList',
widget: 'CheckboxGroup',
options: [
{ value: 'price', label: 'Good price' },
{ value: 'quality', label: 'Build quality' },
{ value: 'look', label: 'It looks good' },
],
},
{
label: 'Current id',
type: 'Number',
defaultValue: async context => Number(await context.getRecordId()),
},
{
label: 'enum',
type: 'Enum',
enumValues: ['opt1', 'opt2'],
},
],
},

{
type: 'Layout',
component: 'Page',
nextButtonLabel: 'Bye',
previousButtonLabel: 'Back',
elements: [
{ component: 'Separator', type: 'Layout' },
{ type: 'Layout', component: 'HtmlBlock', content: '<h1>Thank you</h1>' },
{ component: 'Separator', type: 'Layout' },
{
component: 'Row',
type: 'Layout',
fields: [
{ label: 'Rating again', type: 'Number' },
{ label: 'Put a comment again', type: 'String' },
],
},
],
},
],
execute: async context => {
const rating = Number(context.formValues.rating);
const comment = context.formValues['Put a comment'];
const metadata = context.formValues.Metadata;

const { id } = await context.getRecord(['id']);
await context.dataSource.getCollection('restaurants').update(
{
conditionTree: { field: 'id', operator: 'Equal', value: id },
},
{ comment, rating, metadata },
);
},
});
});
};

const createTable = async () => {
sequelize = await buildSequelizeInstance({ dialect: 'sqlite', storage }, logger);

sequelize.define(
'restaurants',
{
name: { type: DataTypes.STRING },
rating: { type: DataTypes.INTEGER },
comment: { type: DataTypes.STRING },
metadata: { type: DataTypes.JSONB },
},
{ tableName: 'restaurants' },
);
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 createdRestaurant = await sequelize.models.restaurants.create({
name: 'Best Forest Restaurant',
rating: null,
comment: null,
});
restaurantId = createdRestaurant.dataValues.id;
});

describe('when the rating is > 4', () => {
it('should add a comment, rating and metadata', async () => {
const action = await testableAgent
.collection('restaurants')
.action('Leave a review', { recordIds: [restaurantId] });
expect(action.doesFieldExist('Put a comment')).toEqual(false);

const fieldRating = action.getFieldNumber('rating');
await fieldRating.fill(5);

expect(action.doesFieldExist('Put a comment')).toEqual(true);

const commentField = action.getFieldString('Put a comment');
await commentField.fill('A very nice restaurant');

const metadataField = action.getFieldJson('Metadata');
await metadataField.fill({ key: 'value' });

await action.execute();

// fetch the restaurant to check the rating and comment
const [restaurant] = await testableAgent
.collection('restaurants')
.list<{ rating; comment; metadata }>({
filters: { field: 'id', value: restaurantId, operator: 'Equal' },
});

expect(restaurant.rating).toEqual(5);
expect(restaurant.comment).toEqual('A very nice restaurant');
expect(restaurant.metadata).toEqual({ key: 'value' });
});

it('should select the recommend option yes by default', async () => {
const action = await testableAgent
.collection('restaurants')
.action('Leave a review', { recordId: restaurantId });
const recommendField = action.getRadioGroupField('Would you recommend us?');

expect(recommendField.getValue()).toEqual('yes');

await recommendField.check('Not really...');

expect(recommendField.getValue()).toEqual('no');
});

it('should check the different choices', async () => {
const action = await testableAgent
.collection('restaurants')
.action('Leave a review', { recordId: restaurantId });
const likeField = action.getCheckboxGroupField('Why do you like us?');

expect(likeField.getValue()).toBeUndefined();

await likeField.check('Build quality');
await likeField.check('Good price');
await likeField.check('It looks good');
await likeField.uncheck('It looks good');

expect(likeField.getValue()).toEqual(['quality', 'price']);
});
});

it('should handle defaultValue with handler', async () => {
const action = await testableAgent
.collection('restaurants')
.action('Leave a review', { recordId: restaurantId });
const currentIdField = action.getFieldNumber('Current id');

expect(currentIdField.getValue()).toBe(restaurantId);
});

it('check layout on page 0', async () => {
const action = await testableAgent
.collection('restaurants')
.action('Leave a review', { recordId: restaurantId });

expect(action.getLayout().page(0).element(0).isSeparator()).toBe(true);
expect(action.getLayout().page(0).element(2).isHTMLBlock()).toBe(true);
expect(action.getLayout().page(0).element(2).getHtmlBlockContent()).toBe('<h1>Welcome</h1>');
expect(action.getLayout().page(0).element(2).getHtmlBlockContent()).toBe('<h1>Welcome</h1>');
expect(action.getLayout().page(0).nextButtonLabel).toBe('Next');
expect(action.getLayout().page(0).previousButtonLabel).toBe('Back');
});

it('check layout on page 1', async () => {
const action = await testableAgent
.collection('restaurants')
.action('Leave a review', { recordId: restaurantId });

expect(action.getLayout().page(1).element(0).isSeparator()).toBe(true);
expect(action.getLayout().page(1).element(1).isHTMLBlock()).toBe(true);
expect(action.getLayout().page(1).element(1).getHtmlBlockContent()).toBe('<h1>Thank you</h1>');
expect(action.getLayout().page(1).element(2).isSeparator()).toBe(true);
expect(action.getLayout().page(1).nextButtonLabel).toBe('Bye');
expect(action.getLayout().page(1).previousButtonLabel).toBe('Back');

expect(action.getLayout().page(1).element(3).isRow()).toBe(true);
expect(action.getLayout().page(1).element(3).rowElement(0).getInputId()).toEqual(
'Rating again',
);
});

it('should check value on EnumField', async () => {
const action = await testableAgent
.collection('restaurants')
.action('Leave a review', { recordId: restaurantId });

await action.getEnumField('enum').select('opt1');
expect(action.getEnumField('enum').getValue()).toBe('opt1');
});

it('the rating field should be required', async () => {
const action = await testableAgent
.collection('restaurants')
.action('Leave a review', { recordId: restaurantId });

expect(action.getFieldNumber('rating').isRequired()).toBe(true);
});

it('should check the JsonField', async () => {
const action = await testableAgent
.collection('restaurants')
.action('Leave a review', { recordId: restaurantId });

const jsonField = action.getFieldJson('Metadata');
await jsonField.fill({ key: 'value' });

expect(jsonField.getValue()).toEqual({ key: 'value' });
});
});
Loading
Loading