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
This commit is contained in:
@@ -1,610 +1,184 @@
|
||||
import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { loadManifest } from '@/cli/utils/load-manifest';
|
||||
import { type ApplicationConfig } from '@/application';
|
||||
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 write = (root: string, file: string, content: string) => {
|
||||
const abs = join(root, file);
|
||||
ensureDirSync(resolve(abs, '..'));
|
||||
writeFileSync(abs, content, 'utf8');
|
||||
};
|
||||
const TEST_APP_PATH = join(__dirname, '../../__tests__/test-app');
|
||||
|
||||
const tsLibMock = `declare module 'tslib' {
|
||||
export const __decorate: any;
|
||||
export const __metadata: any;
|
||||
export const __param: any;
|
||||
export const __awaiter: any;
|
||||
export const __read: any;
|
||||
export const __spread: any;
|
||||
export const __spreadArray: any;
|
||||
export const __assign: any;
|
||||
}`;
|
||||
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'];
|
||||
|
||||
const twentySdkTypesMock = `
|
||||
declare module 'twenty-sdk' {
|
||||
export type SyncableEntityOptions = { universalIdentifier: string };
|
||||
beforeAll(async () => {
|
||||
const result = await loadManifest(TEST_APP_PATH);
|
||||
|
||||
type ApplicationVariable = SyncableEntityOptions & {
|
||||
value?: string;
|
||||
description?: string;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
export type ApplicationConfig = SyncableEntityOptions & {
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
applicationVariables?: Record<string, ApplicationVariable>;
|
||||
functionRoleUniversalIdentifier?: string;
|
||||
};
|
||||
|
||||
export type RoleConfig = SyncableEntityOptions & {
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
canReadAllObjectRecords?: boolean;
|
||||
canUpdateAllObjectRecords?: boolean;
|
||||
canSoftDeleteAllObjectRecords?: boolean;
|
||||
canDestroyAllObjectRecords?: boolean;
|
||||
objectPermissions?: any[];
|
||||
fieldPermissions?: any[];
|
||||
permissionFlags?: any[];
|
||||
};
|
||||
|
||||
export enum PermissionFlag {
|
||||
API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS',
|
||||
WORKSPACE = 'WORKSPACE',
|
||||
WORKSPACE_MEMBERS = 'WORKSPACE_MEMBERS',
|
||||
ROLES = 'ROLES',
|
||||
DATA_MODEL = 'DATA_MODEL',
|
||||
SECURITY = 'SECURITY',
|
||||
WORKFLOWS = 'WORKFLOWS',
|
||||
IMPERSONATE = 'IMPERSONATE',
|
||||
SSO_BYPASS = 'SSO_BYPASS',
|
||||
APPLICATIONS = 'APPLICATIONS',
|
||||
LAYOUTS = 'LAYOUTS',
|
||||
BILLING = 'BILLING',
|
||||
AI_SETTINGS = 'AI_SETTINGS',
|
||||
|
||||
// Tool permissions
|
||||
AI = 'AI',
|
||||
VIEWS = 'VIEWS',
|
||||
UPLOAD_FILE = 'UPLOAD_FILE',
|
||||
DOWNLOAD_FILE = 'DOWNLOAD_FILE',
|
||||
SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL',
|
||||
HTTP_REQUEST_TOOL = 'HTTP_REQUEST_TOOL',
|
||||
IMPORT_CSV = 'IMPORT_CSV',
|
||||
EXPORT_CSV = 'EXPORT_CSV',
|
||||
CONNECTED_ACCOUNTS = 'CONNECTED_ACCOUNTS',
|
||||
PROFILE_INFORMATION = 'PROFILE_INFORMATION',
|
||||
}
|
||||
|
||||
|
||||
|
||||
type RouteTrigger = {
|
||||
type: 'route';
|
||||
path: string;
|
||||
httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
isAuthRequired: boolean;
|
||||
};
|
||||
|
||||
type CronTrigger = {
|
||||
type: 'cron';
|
||||
pattern: string;
|
||||
};
|
||||
|
||||
type DatabaseEventTrigger = {
|
||||
type: 'databaseEvent';
|
||||
eventName: string;
|
||||
};
|
||||
|
||||
type ServerlessFunctionTrigger = SyncableEntityOptions &
|
||||
(RouteTrigger | CronTrigger | DatabaseEventTrigger);
|
||||
|
||||
export type FunctionConfig = SyncableEntityOptions & {
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
triggers?: ServerlessFunctionTrigger[];
|
||||
};
|
||||
|
||||
type ObjectMetadataOptions = SyncableEntityOptions & {
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export const Object = (_: ObjectMetadataOptions): ClassDecorator => {
|
||||
return () => {};
|
||||
};
|
||||
|
||||
export class BaseObjectMetadata {}
|
||||
|
||||
export enum FieldType {
|
||||
TEXT = 'TEXT',
|
||||
FULL_NAME = 'FULL_NAME',
|
||||
ADDRESS = 'ADDRESS',
|
||||
SELECT = 'SELECT',
|
||||
DATE_TIME = 'DATE_TIME',
|
||||
}
|
||||
|
||||
export const Field: (_: any) => PropertyDecorator;
|
||||
}
|
||||
`;
|
||||
|
||||
const defaultRoleMock = `
|
||||
import { PermissionFlag, type RoleConfig } from 'twenty-sdk';
|
||||
|
||||
export const functionRole: RoleConfig = {
|
||||
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
|
||||
label: 'hello-world-role',
|
||||
description: 'A role to define app permissions',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectNameSingular: 'postCard',
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
fieldPermissions: [
|
||||
{
|
||||
objectNameSingular: 'postCard',
|
||||
fieldName: 'content',
|
||||
canReadFieldValue: false,
|
||||
canUpdateFieldValue: false,
|
||||
},
|
||||
],
|
||||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
const serverlessFunctionMock = `
|
||||
import { type FunctionConfig } from 'twenty-sdk';
|
||||
|
||||
export const main = async (params: any): Promise<any> => {
|
||||
return {};
|
||||
}
|
||||
|
||||
export const config: FunctionConfig = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'hello',
|
||||
timeoutSeconds: 2,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
type: 'route',
|
||||
path: '/post-card/create',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
|
||||
type: 'cron',
|
||||
pattern: '0 0 1 1 *', // Every year 1st of January
|
||||
},
|
||||
{
|
||||
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'person.created'
|
||||
}
|
||||
]
|
||||
};`;
|
||||
|
||||
const objectMock = `import {
|
||||
Object,
|
||||
Field,
|
||||
FieldType
|
||||
} from 'twenty-sdk';
|
||||
|
||||
enum PostCardStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
SENT = 'SENT',
|
||||
DELIVERED = 'DELIVERED',
|
||||
RETURNED = 'RETURNED',
|
||||
}
|
||||
|
||||
@Object({
|
||||
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
nameSingular: 'postCard',
|
||||
namePlural: 'postCards',
|
||||
labelSingular: 'Post card',
|
||||
labelPlural: 'Post cards',
|
||||
description: ' A post card object',
|
||||
icon: 'IconMail',
|
||||
})
|
||||
export class PostCard {
|
||||
@Field({
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldType.TEXT,
|
||||
label: 'Content',
|
||||
description: "Postcard's content",
|
||||
})
|
||||
content: string;
|
||||
|
||||
@Field({
|
||||
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
|
||||
type: FieldType.FULL_NAME,
|
||||
label: 'Recipient name',
|
||||
})
|
||||
recipientName: string;
|
||||
|
||||
@Field({
|
||||
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
|
||||
type: FieldType.ADDRESS,
|
||||
label: 'Recipient address',
|
||||
})
|
||||
recipientAddress: string;
|
||||
|
||||
@Field({
|
||||
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
|
||||
type: FieldType.SELECT,
|
||||
label: 'Status',
|
||||
defaultValue: \`'\${PostCardStatus.DRAFT}'\`,
|
||||
options: [
|
||||
{
|
||||
value: PostCardStatus.DRAFT,
|
||||
label: 'Draft',
|
||||
position: 0,
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
value: PostCardStatus.SENT,
|
||||
label: 'Sent',
|
||||
position: 1,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
value: PostCardStatus.DELIVERED,
|
||||
label: 'Delivered',
|
||||
position: 2,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: PostCardStatus.RETURNED,
|
||||
label: 'Returned',
|
||||
position: 3,
|
||||
color: 'orange',
|
||||
},
|
||||
],
|
||||
})
|
||||
status: 'draft' | 'sent' | 'delivered' | 'returned';
|
||||
|
||||
@Field({
|
||||
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
|
||||
type: FieldType.DATE_TIME,
|
||||
label: 'Delivered at',
|
||||
isNullable: true,
|
||||
defaultValue: null,
|
||||
})
|
||||
deliveredAt?: Date;
|
||||
}
|
||||
`;
|
||||
|
||||
const packageJsonMock = {
|
||||
name: 'my-app',
|
||||
version: '0.0.1',
|
||||
license: 'MIT',
|
||||
engines: {
|
||||
node: '^24.5.0',
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
packageManager: 'yarn@4.9.2',
|
||||
scripts: {
|
||||
'create-entity': 'twenty app add',
|
||||
dev: 'twenty app dev',
|
||||
generate: 'twenty app generate',
|
||||
sync: 'twenty app sync',
|
||||
uninstall: 'twenty app uninstall',
|
||||
auth: 'twenty auth login',
|
||||
},
|
||||
dependencies: {
|
||||
'twenty-sdk': '0.1.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'@types/node': '^24.7.2',
|
||||
typescript: '^5.9.3',
|
||||
},
|
||||
};
|
||||
|
||||
const tsConfigJsonMock = {
|
||||
compileOnSave: false,
|
||||
compilerOptions: {
|
||||
sourceMap: true,
|
||||
declaration: true,
|
||||
outDir: './dist',
|
||||
rootDir: '.',
|
||||
moduleResolution: 'node',
|
||||
allowSyntheticDefaultImports: true,
|
||||
emitDecoratorMetadata: true,
|
||||
experimentalDecorators: true,
|
||||
importHelpers: true,
|
||||
allowUnreachableCode: false,
|
||||
strictNullChecks: true,
|
||||
alwaysStrict: true,
|
||||
noImplicitAny: true,
|
||||
strictBindCallApply: false,
|
||||
target: 'es2018',
|
||||
module: 'esnext',
|
||||
lib: ['es2020', 'dom'],
|
||||
skipLibCheck: true,
|
||||
skipDefaultLibCheck: true,
|
||||
resolveJsonModule: true,
|
||||
},
|
||||
|
||||
exclude: ['node_modules', 'dist', '**/*.test.ts', '**/*.spec.ts'],
|
||||
};
|
||||
|
||||
const yarnLockMock = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
`;
|
||||
|
||||
const applicationMockConfig: ApplicationConfig = {
|
||||
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
|
||||
displayName: 'My App',
|
||||
description: 'My app description',
|
||||
icon: 'IconWorld',
|
||||
applicationVariables: {
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: '3a327392-3a0f-4605-9223-0633f063eaf6',
|
||||
description: 'Twenty API Key',
|
||||
isSecret: true,
|
||||
},
|
||||
TWENTY_API_URL: {
|
||||
universalIdentifier: 'aa7210a6-75b0-46ca-bcbe-09a5b42a76ec',
|
||||
description: 'Twenty API Url',
|
||||
isSecret: false,
|
||||
},
|
||||
},
|
||||
functionRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
|
||||
};
|
||||
|
||||
const applicationConfigMock = `import { type ApplicationConfig } from 'twenty-sdk';
|
||||
|
||||
const config: ApplicationConfig = ${JSON.stringify(applicationMockConfig)};
|
||||
|
||||
export default config;
|
||||
`;
|
||||
|
||||
describe('loadManifest (integration)', () => {
|
||||
const appDirectory = join(tmpdir(), 'test-app');
|
||||
|
||||
beforeEach(async () => {
|
||||
await ensureDirSync(appDirectory);
|
||||
|
||||
write(appDirectory, 'yarn.lock', yarnLockMock);
|
||||
|
||||
write(appDirectory, 'application.config.ts', applicationConfigMock);
|
||||
|
||||
write(
|
||||
appDirectory,
|
||||
'tsconfig.json',
|
||||
JSON.stringify(tsConfigJsonMock, null, 2),
|
||||
);
|
||||
|
||||
write(
|
||||
appDirectory,
|
||||
'package.json',
|
||||
JSON.stringify(packageJsonMock, null, 2),
|
||||
);
|
||||
|
||||
write(appDirectory, 'src/Account.ts', objectMock);
|
||||
|
||||
write(appDirectory, 'src/hello.ts', serverlessFunctionMock);
|
||||
|
||||
write(appDirectory, 'src/defaultRole.ts', defaultRoleMock);
|
||||
|
||||
write(
|
||||
appDirectory,
|
||||
'src/types/twenty-sdk-application.d.ts',
|
||||
twentySdkTypesMock,
|
||||
);
|
||||
|
||||
write(
|
||||
appDirectory,
|
||||
'src/types/tslib.d.ts',
|
||||
// minimal + future-proof
|
||||
tsLibMock,
|
||||
);
|
||||
manifest = result.manifest;
|
||||
packageJson = result.packageJson;
|
||||
yarnLock = result.yarnLock;
|
||||
warnings = result.warnings;
|
||||
shouldGenerate = result.shouldGenerate;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeSync(appDirectory);
|
||||
});
|
||||
|
||||
it('builds a full manifest for a valid workspace', async () => {
|
||||
const { packageJson, yarnLock, manifest } =
|
||||
await loadManifest(appDirectory);
|
||||
|
||||
expect(packageJson.name).toBe('my-app');
|
||||
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');
|
||||
expect(packageJson.license).toBe('MIT');
|
||||
expect(yarnLock).toContain(
|
||||
'# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.',
|
||||
|
||||
// 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');
|
||||
|
||||
// application
|
||||
expect(manifest.application).toEqual(applicationMockConfig);
|
||||
expect(manifest.objects).toHaveLength(1);
|
||||
|
||||
expect(manifest.objects.length).toBe(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');
|
||||
|
||||
for (const object of manifest.objects) {
|
||||
const { universalIdentifier: _, fields, ...otherInfo } = object;
|
||||
expect(otherInfo).toEqual({
|
||||
description: ' A post card object',
|
||||
icon: 'IconMail',
|
||||
labelPlural: 'Post cards',
|
||||
labelSingular: 'Post card',
|
||||
namePlural: 'postCards',
|
||||
nameSingular: 'postCard',
|
||||
});
|
||||
// Check fields
|
||||
expect(postCard.fields).toHaveLength(5);
|
||||
|
||||
expect(Array.isArray(fields)).toBe(true);
|
||||
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');
|
||||
|
||||
expect(fields).toEqual([
|
||||
{
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: 'TEXT',
|
||||
label: 'Content',
|
||||
description: "Postcard's content",
|
||||
name: 'content',
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
|
||||
type: 'FULL_NAME',
|
||||
label: 'Recipient name',
|
||||
name: 'recipientName',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
|
||||
type: 'ADDRESS',
|
||||
label: 'Recipient address',
|
||||
name: 'recipientAddress',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
|
||||
type: 'SELECT',
|
||||
label: 'Status',
|
||||
defaultValue: "'DRAFT'",
|
||||
options: [
|
||||
{ value: 'DRAFT', label: 'Draft', position: 0, color: 'gray' },
|
||||
{ value: 'SENT', label: 'Sent', position: 1, color: 'orange' },
|
||||
{
|
||||
value: 'DELIVERED',
|
||||
label: 'Delivered',
|
||||
position: 2,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: 'RETURNED',
|
||||
label: 'Returned',
|
||||
position: 3,
|
||||
color: 'orange',
|
||||
},
|
||||
],
|
||||
name: 'status',
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
|
||||
type: 'DATE_TIME',
|
||||
label: 'Delivered at',
|
||||
isNullable: true,
|
||||
defaultValue: null,
|
||||
name: 'deliveredAt',
|
||||
},
|
||||
]);
|
||||
}
|
||||
const statusField = postCard.fields?.find((f) => f.name === 'status');
|
||||
expect(statusField).toBeDefined();
|
||||
expect(statusField?.type).toBe('SELECT');
|
||||
expect(statusField?.options).toHaveLength(4);
|
||||
|
||||
// serverless functions
|
||||
for (const serverlessFunction of manifest.serverlessFunctions) {
|
||||
const {
|
||||
universalIdentifier: _,
|
||||
handlerPath: __,
|
||||
triggers,
|
||||
...otherInfo
|
||||
} = serverlessFunction;
|
||||
expect(manifest.serverlessFunctions).toHaveLength(2);
|
||||
|
||||
expect(otherInfo).toEqual({
|
||||
handlerName: 'main',
|
||||
name: 'hello',
|
||||
timeoutSeconds: 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');
|
||||
|
||||
for (const trigger of triggers) {
|
||||
const { universalIdentifier: _, ...otherInfo } = trigger;
|
||||
switch (trigger.type) {
|
||||
case 'route':
|
||||
expect(otherInfo).toEqual({
|
||||
isAuthRequired: false,
|
||||
httpMethod: 'GET',
|
||||
path: '/post-card/create',
|
||||
type: 'route',
|
||||
});
|
||||
break;
|
||||
case 'cron':
|
||||
expect(otherInfo).toEqual({
|
||||
pattern: '0 0 1 1 *',
|
||||
type: 'cron',
|
||||
});
|
||||
break;
|
||||
case 'databaseEvent':
|
||||
expect(otherInfo).toEqual({
|
||||
eventName: 'person.created',
|
||||
type: 'databaseEvent',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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');
|
||||
|
||||
//Role
|
||||
expect(manifest.roles).toHaveLength(1);
|
||||
|
||||
for (const role of manifest.roles ?? []) {
|
||||
const {
|
||||
universalIdentifier: _,
|
||||
objectPermissions,
|
||||
fieldPermissions,
|
||||
permissionFlags,
|
||||
...otherInfo
|
||||
} = role;
|
||||
expect(otherInfo).toEqual({
|
||||
label: 'hello-world-role',
|
||||
description: 'A role to define app permissions',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
});
|
||||
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);
|
||||
|
||||
expect(Array.isArray(objectPermissions)).toBe(true);
|
||||
expect(Array.isArray(fieldPermissions)).toBe(true);
|
||||
expect(Array.isArray(permissionFlags)).toBe(true);
|
||||
}
|
||||
});
|
||||
// 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);
|
||||
|
||||
it('should not define serverless for util file', async () => {
|
||||
write(
|
||||
appDirectory,
|
||||
'src/utils/format.ts',
|
||||
`
|
||||
export const format = async (params: any): Promise<any> => {
|
||||
return {};
|
||||
}
|
||||
`,
|
||||
// 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 { manifest } = await loadManifest(appDirectory);
|
||||
expect(manifest.serverlessFunctions.length).toBe(1);
|
||||
});
|
||||
const linkedRole = manifest.roles?.find(
|
||||
(r) => r.universalIdentifier === expectedRoleId,
|
||||
);
|
||||
expect(linkedRole).toBeDefined();
|
||||
|
||||
it('manifest should contains typescript sources', async () => {
|
||||
const { manifest } = await loadManifest(appDirectory);
|
||||
// the method is already exercised in loadManifest; just assert again:
|
||||
expect(Object.keys(manifest.sources)).toEqual([
|
||||
'application.config.ts',
|
||||
'src',
|
||||
]);
|
||||
expect(Object.keys(manifest.sources['src'])).toEqual([
|
||||
'Account.ts',
|
||||
'defaultRole.ts',
|
||||
'hello.ts',
|
||||
]);
|
||||
});
|
||||
expect(manifest.application.applicationVariables).toBeDefined();
|
||||
|
||||
it('manifest should contains typescript sources', async () => {
|
||||
const { shouldGenerate } = await loadManifest(appDirectory);
|
||||
expect(shouldGenerate).toBe(false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user