Files
twenty/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts
T
martmull 40eef5c464 Improve application ast (#17016)
# Summary

- Introduces a new, flexible folder structure for Twenty SDK
applications using file suffix-based entity detection
- Adds defineApp, defineFunction, defineObject, and defineRole helper
functions with built-in validation
- Refactors manifest loading to use jiti runtime evaluation for
TypeScript config files
- Separates validation logic into dedicated module with comprehensive
error reporting

  # New Application Folder Structure

Applications now use a convention-over-configuration approach where
entities are detected by their file suffix, allowing flexible
organization within the src/app/ folder.

  # Required Structure

    my-app/
    ├── package.json
    ├── yarn.lock
    └── src/
        ├── app/
│ └── application.config.ts # Required - main application configuration
└── utils/ # Optional - handler implementations & utilities

  # Entity Detection by File Suffix

    - *.object.ts - Custom object definitions
    - *.function.ts - Serverless function definitions
    - *.role.ts - Role definitions

  # Supported Folder Organizations

  ## Traditional (by type):
    src/app/
    ├── application.config.ts
    ├── objects/
    │   └── postCard.object.ts
    ├── functions/
    │   └── createPostCard.function.ts
    └── roles/
        └── admin.role.ts

  ## Feature-based:
    src/app/
    ├── application.config.ts
    └── post-card/
        ├── postCard.object.ts
        ├── createPostCard.function.ts
        └── postCardAdmin.role.ts

  ## Flat:
    src/app/
    ├── application.config.ts
    ├── postCard.object.ts
    ├── createPostCard.function.ts
    └── admin.role.ts

  # New Helper Functions

  ## defineApp(config)

    import { defineApp } from 'twenty-sdk';

    export default defineApp({
      universalIdentifier: '4ec0391d-...',
      displayName: 'My App',
      description: 'App description',
      icon: 'IconWorld',
    });

  ## defineObject(config)

    import { defineObject, FieldType } from 'twenty-sdk';

    export default defineObject({
      universalIdentifier: '54b589ca-...',
      nameSingular: 'postCard',
      namePlural: 'postCards',
      labelSingular: 'Post Card',
      labelPlural: 'Post Cards',
      icon: 'IconMail',
      fields: [
        {
          universalIdentifier: '58a0a314-...',
          type: FieldType.TEXT,
          name: 'content',
          label: 'Content',
        },
      ],
    });

  ## defineFunction(config)

    import { defineFunction } from 'twenty-sdk';
    import { myHandler } from '../utils/my-handler';

    export default defineFunction({
      universalIdentifier: 'e56d363b-...',
      name: 'My Function',
      handler: myHandler,
      triggers: [
        {
          universalIdentifier: 'c9f84c8d-...',
          type: 'route',
          path: '/my-route',
          httpMethod: 'POST',
        },
      ],
    });

  ## defineRole(config)

    import { defineRole, PermissionFlag } from 'twenty-sdk';

    export default defineRole({
      universalIdentifier: 'b648f87b-...',
      label: 'App User',
      objectPermissions: [
        {
          objectNameSingular: 'postCard',
          canReadObjectRecords: true,
        },
      ],
      permissionFlags: [PermissionFlag.UPLOAD_FILE],
    });

  # Test plan

    - Verify npx twenty app sync works with new folder structure
    - Verify npx twenty app dev works with new folder structure
    - Verify validation errors display correctly for invalid configs
- Verify all three folder organization styles work (traditional,
feature-based, flat)
    - Run existing E2E tests to ensure backward compatibility
2026-01-09 13:06:30 +00:00

185 lines
7.0 KiB
TypeScript

import { join } from 'path';
import {
loadManifest,
type LoadManifestResult,
} from '@/cli/utils/load-manifest';
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/test-app/src/app/default-function.role';
const TEST_APP_PATH = join(__dirname, '../../__tests__/test-app');
describe('loadManifest with test-app', () => {
let manifest: LoadManifestResult['manifest'];
let packageJson: LoadManifestResult['packageJson'];
let yarnLock: LoadManifestResult['yarnLock'];
let warnings: LoadManifestResult['warnings'];
let shouldGenerate: LoadManifestResult['shouldGenerate'];
beforeAll(async () => {
const result = await loadManifest(TEST_APP_PATH);
manifest = result.manifest;
packageJson = result.packageJson;
yarnLock = result.yarnLock;
warnings = result.warnings;
shouldGenerate = result.shouldGenerate;
});
it('should load manifest from test-app directory', async () => {
// Check package.json loaded correctly
expect(packageJson.name).toBe('test-app');
expect(packageJson.version).toBe('0.0.1');
// Check yarn.lock exists (can be empty)
expect(yarnLock).toBeDefined();
// Check no warnings
expect(warnings).toEqual([]);
// Check application config
expect(manifest.application).toBeDefined();
expect(manifest.application.universalIdentifier).toBe(
'4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
);
expect(manifest.application.displayName).toBe('Hello World');
expect(manifest.application.description).toBe('A simple hello world app');
expect(manifest.application.icon).toBe('IconWorld');
expect(manifest.objects).toHaveLength(1);
const postCard = manifest.objects[0];
expect(postCard.universalIdentifier).toBe(
'54b589ca-eeed-4950-a176-358418b85c05',
);
expect(postCard.nameSingular).toBe('postCard');
expect(postCard.namePlural).toBe('postCards');
expect(postCard.labelSingular).toBe('Post card');
expect(postCard.labelPlural).toBe('Post cards');
expect(postCard.icon).toBe('IconMail');
// Check fields
expect(postCard.fields).toHaveLength(5);
const contentField = postCard.fields?.find((f) => f.name === 'content');
expect(contentField).toBeDefined();
expect(contentField?.universalIdentifier).toBe(
'58a0a314-d7ea-4865-9850-7fb84e72f30b',
);
expect(contentField?.type).toBe('TEXT');
expect(contentField?.label).toBe('Content');
const statusField = postCard.fields?.find((f) => f.name === 'status');
expect(statusField).toBeDefined();
expect(statusField?.type).toBe('SELECT');
expect(statusField?.options).toHaveLength(4);
expect(manifest.serverlessFunctions).toHaveLength(2);
const testFunction = manifest.serverlessFunctions[1];
expect(testFunction.universalIdentifier).toBe(
'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
);
expect(testFunction.name).toBe('test-function');
expect(testFunction.timeoutSeconds).toBe(2);
expect(testFunction.handlerName).toBe('handler');
expect(testFunction.handlerPath).toBe('src/app/test-function.function.ts');
// Check triggers
expect(testFunction.triggers).toHaveLength(3);
const routeTrigger = testFunction.triggers.find((t) => t.type === 'route');
expect(routeTrigger).toBeDefined();
expect(routeTrigger?.universalIdentifier).toBe(
'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
);
expect(routeTrigger?.path).toBe('/post-card/create');
expect(routeTrigger?.httpMethod).toBe('GET');
const cronTrigger = testFunction.triggers.find((t) => t.type === 'cron');
expect(cronTrigger).toBeDefined();
expect(cronTrigger?.pattern).toBe('0 0 1 1 *');
const dbEventTrigger = testFunction.triggers.find(
(t) => t.type === 'databaseEvent',
);
expect(dbEventTrigger).toBeDefined();
expect(dbEventTrigger?.eventName).toBe('person.created');
// Second function
const testFunction2 = manifest.serverlessFunctions[0];
expect(testFunction2.universalIdentifier).toBe(
'eb3ffc98-88ec-45d4-9b4a-56833b219ccb',
);
expect(testFunction2.name).toBe('test-function-2');
expect(testFunction2.timeoutSeconds).toBe(2);
expect(testFunction2.handlerName).toBe('testFunction2');
expect(testFunction2.handlerPath).toBe('src/utils/test-function-2.util.ts');
expect(manifest.roles).toHaveLength(1);
const role = manifest.roles![0];
expect(role.universalIdentifier).toBe(
'b648f87b-1d26-4961-b974-0908fd991061',
);
expect(role.label).toBe('Default function role');
expect(role.description).toBe('Default role for function Twenty client');
expect(role.canReadAllObjectRecords).toBe(false);
expect(role.canUpdateAllObjectRecords).toBe(false);
// Check object permissions
expect(role.objectPermissions).toHaveLength(1);
expect(role.objectPermissions![0].objectNameSingular).toBe('postCard');
expect(role.objectPermissions![0].canReadObjectRecords).toBe(true);
expect(role.objectPermissions![0].canUpdateObjectRecords).toBe(true);
// Check field permissions
expect(role.fieldPermissions).toHaveLength(1);
expect(role.fieldPermissions![0].objectNameSingular).toBe('postCard');
expect(role.fieldPermissions![0].fieldName).toBe('content');
expect(role.fieldPermissions![0].canReadFieldValue).toBe(false);
expect(manifest.sources).toBeDefined();
expect(manifest.sources['src']).toBeDefined();
// Check that the source files are loaded
const srcSources = manifest.sources['src'] as Record<string, unknown>;
const appSources = srcSources['app'] as Record<string, string>;
expect(appSources['application.config.ts']).toBeDefined();
expect(appSources['postCard.object.ts']).toBeDefined();
expect(appSources['test-function.function.ts']).toBeDefined();
expect(appSources['default-function.role.ts']).toBeDefined();
// Verify source content contains expected code
expect(appSources['application.config.ts']).toContain('defineApp');
expect(appSources['postCard.object.ts']).toContain('defineObject');
expect(appSources['test-function.function.ts']).toContain('defineFunction');
expect(appSources['default-function.role.ts']).toContain('defineRole');
expect(shouldGenerate).toBe(false);
const expectedRoleId = DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER;
expect(manifest.application.functionRoleUniversalIdentifier).toBe(
expectedRoleId,
);
const linkedRole = manifest.roles?.find(
(r) => r.universalIdentifier === expectedRoleId,
);
expect(linkedRole).toBeDefined();
expect(manifest.application.applicationVariables).toBeDefined();
const defaultRecipient =
manifest.application.applicationVariables?.DEFAULT_RECIPIENT_NAME;
expect(defaultRecipient).toBeDefined();
expect(defaultRecipient?.universalIdentifier).toBe(
'19e94e59-d4fe-4251-8981-b96d0a9f74de',
);
expect(defaultRecipient?.description).toBe(
'Default recipient name for postcards',
);
expect(defaultRecipient?.value).toBe('Alex Karp');
expect(defaultRecipient?.isSecret).toBe(false);
});
});