Enhance tests on SDK (#17312)

![20260121_180621](https://github.com/user-attachments/assets/9284f2b1-6b4e-40fb-abf1-9c981fcb7166)
This commit is contained in:
Charles Bochet
2026-01-21 19:27:04 +01:00
committed by GitHub
parent 981956a636
commit cb4110e894
50 changed files with 792 additions and 311 deletions
+50 -49
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
task: [lint, typecheck, test:unit, test:integration]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -44,58 +44,59 @@ jobs:
with:
tag: scope:sdk
tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
NODE_ENV: test
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Server / Append billing config to .env.test
working-directory: packages/twenty-server
run: |
echo "" >> .env.test
echo "IS_BILLING_ENABLED=true" >> .env.test
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: SDK / Run E2E Tests
run: npx nx test:e2e twenty-sdk
# TODO: Re-enable sdk-e2e-test once application sync is stable
# sdk-e2e-test:
# timeout-minutes: 30
# runs-on: depot-ubuntu-24.04-8
# needs: [changed-files-check, sdk-test]
# if: needs.changed-files-check.outputs.any_changed == 'true'
# services:
# postgres:
# image: twentycrm/twenty-postgres-spilo
# env:
# PGUSER_SUPERUSER: postgres
# PGPASSWORD_SUPERUSER: postgres
# ALLOW_NOSSL: 'true'
# SPILO_PROVIDER: 'local'
# ports:
# - 5432:5432
# options: >-
# --health-cmd pg_isready
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# redis:
# image: redis
# ports:
# - 6379:6379
# env:
# NODE_ENV: test
# steps:
# - name: Fetch custom Github Actions and base branch history
# uses: actions/checkout@v4
# with:
# fetch-depth: 0
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - name: Server / Append billing config to .env.test
# working-directory: packages/twenty-server
# run: |
# echo "" >> .env.test
# echo "IS_BILLING_ENABLED=true" >> .env.test
# echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
# echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
# echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
# echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
# - name: Server / Create Test DB
# run: |
# PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
# - name: SDK / Run E2E Tests
# run: npx nx test:e2e twenty-sdk
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, sdk-test, sdk-e2e-test]
needs: [changed-files-check, sdk-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
+2
View File
@@ -0,0 +1,2 @@
node_modules
.twenty
+14
View File
@@ -86,6 +86,20 @@
}
}
},
"test:unit": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-sdk",
"command": "npx vitest run --config vitest.unit.config.ts"
}
},
"test:integration": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-sdk",
"command": "npx vitest run --config vitest.integration.config.ts"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"options": {
@@ -0,0 +1,15 @@
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
import { join } from 'path';
const APP_PATH = join(__dirname, '..');
describe('invalid-app manifest', () => {
it('should fail to build manifest due to duplicate universalIdentifier', async () => {
const manifest = await runManifestBuild(APP_PATH, {
display: false,
writeOutput: false,
});
expect(manifest).toBeNull();
});
});
@@ -0,0 +1,21 @@
{
"name": "invalid-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": {
"dev": "twenty app dev",
"sync": "twenty app sync"
},
"dependencies": {
"twenty-sdk": "latest"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -0,0 +1,8 @@
import { defineApp } from '@/application/define-app';
export default defineApp({
universalIdentifier: 'invalid-app-0000-0000-0000-000000000001',
displayName: 'Invalid App',
description: 'An app with duplicate IDs for testing validation',
icon: 'IconAlertTriangle',
});
@@ -0,0 +1,22 @@
import { defineObject } from '@/application/objects/define-object';
import { FieldType } from '@/application/fields/field-type';
const DUPLICATE_ID = 'duplicate-id-0000-0000-000000000001';
export default defineObject({
universalIdentifier: DUPLICATE_ID,
nameSingular: 'firstObject',
namePlural: 'firstObjects',
labelSingular: 'First object',
labelPlural: 'First objects',
description: 'First object with duplicate ID',
icon: 'IconBox',
fields: [
{
universalIdentifier: 'first-field-0000-0000-000000000001',
type: FieldType.TEXT,
label: 'Name',
name: 'name',
},
],
});
@@ -0,0 +1,22 @@
import { defineObject } from '@/application/objects/define-object';
import { FieldType } from '@/application/fields/field-type';
const DUPLICATE_ID = 'duplicate-id-0000-0000-000000000001';
export default defineObject({
universalIdentifier: DUPLICATE_ID,
nameSingular: 'secondObject',
namePlural: 'secondObjects',
labelSingular: 'Second object',
labelPlural: 'Second objects',
description: 'Second object with duplicate ID',
icon: 'IconBox',
fields: [
{
universalIdentifier: 'second-field-0000-0000-000000000001',
type: FieldType.TEXT,
label: 'Title',
name: 'title',
},
],
});
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["../../../../../src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
@@ -6,8 +6,8 @@ import { inspect } from 'util';
inspect.defaultOptions.depth = 10;
describe('Application: install delete and reinstall test-app', () => {
const applicationName = 'test-app';
describe('Application: install delete and reinstall rich-app', () => {
const applicationName = 'rich-app';
const syncCommand = new AppSyncCommand();
const deleteCommand = new AppUninstallCommand();
const appPath = getTestedApplicationPath(applicationName);
@@ -0,0 +1,319 @@
{
"application": {
"applicationVariables": {
"DEFAULT_RECIPIENT_NAME": {
"description": "Default recipient name for postcards",
"isSecret": false,
"universalIdentifier": "19e94e59-d4fe-4251-8981-b96d0a9f74de",
"value": "Alex Karp"
}
},
"description": "A simple hello world app",
"displayName": "Hello World",
"functionRoleUniversalIdentifier": "b648f87b-1d26-4961-b974-0908fd991061",
"icon": "IconWorld",
"universalIdentifier": "4ec0391d-18d5-411c-b2f3-266ddc1c3ef7"
},
"frontComponents": [
{
"componentName": "RootComponent",
"componentPath": "src/root.front-component.tsx",
"description": "A root-level front component",
"name": "root-component",
"universalIdentifier": "a0a1a2a3-a4a5-4000-8000-000000000001"
},
{
"componentName": "CardDisplay",
"componentPath": "src/utils/card-display.component.tsx",
"description": "A component using an external component file",
"name": "card-component",
"universalIdentifier": "i0i1i2i3-i4i5-4000-8000-000000000001"
},
{
"componentName": "GreetingComponent",
"componentPath": "src/components/greeting.front-component.tsx",
"description": "A component that uses greeting utility",
"name": "greeting-component",
"universalIdentifier": "h0h1h2h3-h4h5-4000-8000-000000000001"
},
{
"componentName": "TestComponent",
"componentPath": "src/components/test.front-component.tsx",
"description": "A test front component",
"name": "test-component",
"universalIdentifier": "f1234567-abcd-4000-8000-000000000001"
}
],
"objectExtensions": [
{
"fields": [
{
"description": "Priority level for the post card (1-10)",
"label": "Priority",
"name": "priority",
"type": "NUMBER",
"universalIdentifier": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
},
{
"description": "Post card category",
"label": "Category",
"name": "category",
"options": [
{
"color": "blue",
"label": "Personal",
"position": 0,
"value": "PERSONAL"
},
{
"color": "green",
"label": "Business",
"position": 1,
"value": "BUSINESS"
},
{
"color": "orange",
"label": "Promotional",
"position": 2,
"value": "PROMOTIONAL"
}
],
"type": "SELECT",
"universalIdentifier": "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e"
}
],
"targetObject": {
"nameSingular": "postCard"
}
}
],
"objects": [
{
"description": "A simple root-level object",
"fields": [
{
"label": "Title",
"name": "title",
"type": "TEXT",
"universalIdentifier": "b0b1b2b3-b4b5-4000-8000-000000000002"
},
{
"label": "Body",
"name": "body",
"type": "TEXT",
"universalIdentifier": "b0b1b2b3-b4b5-4000-8000-000000000003"
}
],
"icon": "IconNote",
"labelPlural": "Root notes",
"labelSingular": "Root note",
"namePlural": "rootNotes",
"nameSingular": "rootNote",
"universalIdentifier": "b0b1b2b3-b4b5-4000-8000-000000000001"
},
{
"description": "A post card object",
"fields": [
{
"description": "Postcard's content",
"icon": "IconAbc",
"label": "Content",
"name": "content",
"type": "TEXT",
"universalIdentifier": "58a0a314-d7ea-4865-9850-7fb84e72f30b"
},
{
"icon": "IconUser",
"label": "Recipient name",
"name": "recipientName",
"type": "FULL_NAME",
"universalIdentifier": "c6aa31f3-da76-4ac6-889f-475e226009ac"
},
{
"icon": "IconHome",
"label": "Recipient address",
"name": "recipientAddress",
"type": "ADDRESS",
"universalIdentifier": "95045777-a0ad-49ec-98f9-22f9fc0c8266"
},
{
"defaultValue": "'DRAFT'",
"icon": "IconSend",
"label": "Status",
"name": "status",
"options": [
{
"color": "gray",
"label": "Draft",
"position": 0,
"value": "DRAFT"
},
{
"color": "orange",
"label": "Sent",
"position": 1,
"value": "SENT"
},
{
"color": "green",
"label": "Delivered",
"position": 2,
"value": "DELIVERED"
},
{
"color": "orange",
"label": "Returned",
"position": 3,
"value": "RETURNED"
}
],
"type": "SELECT",
"universalIdentifier": "87b675b8-dd8c-4448-b4ca-20e5a2234a1e"
},
{
"defaultValue": null,
"icon": "IconCheck",
"isNullable": true,
"label": "Delivered at",
"name": "deliveredAt",
"type": "DATE_TIME",
"universalIdentifier": "e06abe72-5b44-4e7f-93be-afc185a3c433"
}
],
"icon": "IconMail",
"labelPlural": "Post cards",
"labelSingular": "Post card",
"namePlural": "postCards",
"nameSingular": "postCard",
"universalIdentifier": "54b589ca-eeed-4950-a176-358418b85c05"
}
],
"packageJson": {
"name": "rich-app"
},
"roles": [
{
"canBeAssignedToAgents": false,
"canBeAssignedToApiKeys": false,
"canBeAssignedToUsers": true,
"canDestroyAllObjectRecords": false,
"canReadAllObjectRecords": true,
"canSoftDeleteAllObjectRecords": false,
"canUpdateAllObjectRecords": false,
"canUpdateAllSettings": false,
"description": "A simple root-level role",
"label": "Root role",
"universalIdentifier": "c0c1c2c3-c4c5-4000-8000-000000000001"
},
{
"canBeAssignedToAgents": false,
"canBeAssignedToApiKeys": false,
"canBeAssignedToUsers": false,
"canDestroyAllObjectRecords": false,
"canReadAllObjectRecords": false,
"canSoftDeleteAllObjectRecords": false,
"canUpdateAllObjectRecords": false,
"canUpdateAllSettings": false,
"description": "Default role for function Twenty client",
"fieldPermissions": [
{
"canReadFieldValue": false,
"canUpdateFieldValue": false,
"fieldName": "content",
"objectNameSingular": "postCard"
}
],
"label": "Default function role",
"objectPermissions": [
{
"canDestroyObjectRecords": false,
"canReadObjectRecords": true,
"canSoftDeleteObjectRecords": false,
"canUpdateObjectRecords": true,
"objectNameSingular": "postCard"
}
],
"permissionFlags": [
"APPLICATIONS"
],
"universalIdentifier": "b648f87b-1d26-4961-b974-0908fd991061"
}
],
"serverlessFunctions": [
{
"handlerName": "rootHandler",
"handlerPath": "src/root.function.ts",
"name": "root-function",
"timeoutSeconds": 5,
"triggers": [
{
"httpMethod": "GET",
"isAuthRequired": false,
"path": "/root",
"type": "route",
"universalIdentifier": "f0f1f2f3-f4f5-4000-8000-000000000002"
}
],
"universalIdentifier": "f0f1f2f3-f4f5-4000-8000-000000000001"
},
{
"handlerName": "greetingHandler",
"handlerPath": "src/functions/greeting.function.ts",
"name": "greeting-function",
"timeoutSeconds": 5,
"triggers": [
{
"httpMethod": "GET",
"isAuthRequired": false,
"path": "/greet",
"type": "route",
"universalIdentifier": "g0g1g2g3-g4g5-4000-8000-000000000002"
}
],
"universalIdentifier": "g0g1g2g3-g4g5-4000-8000-000000000001"
},
{
"handlerName": "testFunction2",
"handlerPath": "src/utils/test-function-2.util.ts",
"name": "test-function-2",
"timeoutSeconds": 2,
"triggers": [
{
"pattern": "0 0 1 1 *",
"type": "cron",
"universalIdentifier": "9fd0dda9-4664-4fbc-9656-509f4477b9ff"
}
],
"universalIdentifier": "eb3ffc98-88ec-45d4-9b4a-56833b219ccb"
},
{
"handlerName": "handler",
"handlerPath": "src/functions/test-function.function.ts",
"name": "test-function",
"timeoutSeconds": 2,
"triggers": [
{
"forwardedRequestHeaders": [
"signature"
],
"httpMethod": "GET",
"isAuthRequired": false,
"path": "/post-card/create",
"type": "route",
"universalIdentifier": "c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6"
},
{
"pattern": "0 0 1 1 *",
"type": "cron",
"universalIdentifier": "dd802808-0695-49e1-98c9-d5c9e2704ce2"
},
{
"eventName": "person.created",
"type": "databaseEvent",
"universalIdentifier": "203f1df3-4a82-4d06-a001-b8cf22a31156"
}
],
"universalIdentifier": "e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf"
}
]
}
@@ -0,0 +1,49 @@
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
import { join } from 'path';
import expectedManifest from './manifest.expected.json';
const APP_PATH = join(__dirname, '..');
describe('rich-app manifest', () => {
it('should build manifest matching expected JSON', async () => {
const manifest = await runManifestBuild(APP_PATH, {
display: false,
writeOutput: false,
});
expect(manifest).not.toBeNull();
const { sources: _sources, ...sanitizedManifest } = {
...manifest,
packageJson: {
name: manifest!.packageJson.name,
},
};
expect(sanitizedManifest).toEqual(expectedManifest);
});
it('should have correct application config', async () => {
const manifest = await runManifestBuild(APP_PATH, {
display: false,
writeOutput: false,
});
expect(manifest?.application.displayName).toBe('Hello World');
expect(manifest?.application.description).toBe('A simple hello world app');
});
it('should load all entity types', async () => {
const manifest = await runManifestBuild(APP_PATH, {
display: false,
writeOutput: false,
});
expect(manifest?.objects).toHaveLength(2);
expect(manifest?.serverlessFunctions).toHaveLength(4);
expect(manifest?.frontComponents).toHaveLength(4);
expect(manifest?.roles).toHaveLength(2);
expect(manifest?.objectExtensions).toHaveLength(1);
});
});
@@ -1,5 +1,5 @@
{
"name": "test-app",
"name": "rich-app",
"version": "0.0.1",
"license": "MIT",
"engines": {
@@ -1,5 +1,5 @@
import { defineApp } from '@/application/define-app';
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './default-function.role';
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from './roles/default-function.role';
export default defineApp({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -0,0 +1,9 @@
import { defineFrontComponent } from '@/application/front-components/define-front-component';
import { CardDisplay } from '../utils/card-display.component';
export default defineFrontComponent({
universalIdentifier: 'i0i1i2i3-i4i5-4000-8000-000000000001',
name: 'card-component',
description: 'A component using an external component file',
component: CardDisplay,
});
@@ -0,0 +1,19 @@
import { defineFrontComponent } from '@/application/front-components/define-front-component';
import { DEFAULT_NAME, formatGreeting } from '../utils/greeting.util';
const GreetingComponent = () => {
const message = formatGreeting(DEFAULT_NAME);
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>{message}</h1>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'h0h1h2h3-h4h5-4000-8000-000000000001',
name: 'greeting-component',
description: 'A component that uses greeting utility',
component: GreetingComponent,
});
@@ -0,0 +1,22 @@
import { defineFunction } from '@/application/functions/define-function';
import { DEFAULT_NAME, formatGreeting } from '../utils/greeting.util';
const greetingHandler = () => {
return formatGreeting(DEFAULT_NAME);
};
export default defineFunction({
universalIdentifier: 'g0g1g2g3-g4g5-4000-8000-000000000001',
name: 'greeting-function',
timeoutSeconds: 5,
handler: greetingHandler,
triggers: [
{
universalIdentifier: 'g0g1g2g3-g4g5-4000-8000-000000000002',
type: 'route',
path: '/greet',
httpMethod: 'GET',
isAuthRequired: false,
},
],
});
@@ -10,7 +10,7 @@ export const config = defineFunction({
{
universalIdentifier: '9fd0dda9-4664-4fbc-9656-509f4477b9ff',
type: 'cron',
pattern: '0 0 1 1 *', // Every year 1st of January
pattern: '0 0 1 1 *',
},
],
});
@@ -21,7 +21,7 @@ export default defineFunction({
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron',
pattern: '0 0 1 1 *', // Every year 1st of January
pattern: '0 0 1 1 *',
},
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
@@ -0,0 +1,16 @@
import { defineFrontComponent } from '@/application/front-components/define-front-component';
export const RootComponent = () => {
return (
<div style={{ padding: '10px' }}>
<h2>Root Component</h2>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'a0a1a2a3-a4a5-4000-8000-000000000001',
name: 'root-component',
description: 'A root-level front component',
component: RootComponent,
});
@@ -0,0 +1,21 @@
import { defineFunction } from '@/application/functions/define-function';
const rootHandler = () => {
return 'root-function-result';
};
export default defineFunction({
universalIdentifier: 'f0f1f2f3-f4f5-4000-8000-000000000001',
name: 'root-function',
timeoutSeconds: 5,
handler: rootHandler,
triggers: [
{
universalIdentifier: 'f0f1f2f3-f4f5-4000-8000-000000000002',
type: 'route',
path: '/root',
httpMethod: 'GET',
isAuthRequired: false,
},
],
});
@@ -0,0 +1,26 @@
import { defineObject } from '@/application/objects/define-object';
import { FieldType } from '@/application/fields/field-type';
export default defineObject({
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000001',
nameSingular: 'rootNote',
namePlural: 'rootNotes',
labelSingular: 'Root note',
labelPlural: 'Root notes',
description: 'A simple root-level object',
icon: 'IconNote',
fields: [
{
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000002',
type: FieldType.TEXT,
label: 'Title',
name: 'title',
},
{
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000003',
type: FieldType.TEXT,
label: 'Body',
name: 'body',
},
],
});
@@ -0,0 +1,15 @@
import { defineRole } from '@/application/roles/define-role';
export default defineRole({
universalIdentifier: 'c0c1c2c3-c4c5-4000-8000-000000000001',
label: 'Root role',
description: 'A simple root-level role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: false,
});
@@ -0,0 +1,14 @@
export const CardDisplay = ({
title,
content,
}: {
title: string;
content: string;
}) => {
return (
<div style={{ border: '1px solid #ccc', padding: '16px', borderRadius: '8px' }}>
<h3>{title}</h3>
<p>{content}</p>
</div>
);
};
@@ -0,0 +1,9 @@
export const DEFAULT_NAME = 'World';
export const formatGreeting = (name: string): string => {
return `Hello, ${name}!`;
};
export const formatFarewell = (name: string): string => {
return `Goodbye, ${name}!`;
};
@@ -9,7 +9,7 @@
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["../../../../src/*"]
"@/*": ["../../../../../src/*"]
}
},
"include": ["src/**/*"]
@@ -1,7 +1,7 @@
import path from 'path';
export const getTestedApplicationPath = (relativePath: string): string => {
const twentyAppsPath = path.resolve(__dirname, '../..');
export const getTestedApplicationPath = (appName: string): string => {
const appsPath = path.resolve(__dirname, '../../apps');
return path.join(twentyAppsPath, relativePath);
return path.join(appsPath, appName);
};
@@ -11,7 +11,7 @@ import camelcase from 'lodash.camelcase';
import kebabcase from 'lodash.kebabcase';
import { join } from 'path';
const APP_FOLDER = 'src/app';
const APP_FOLDER = 'src';
export enum SyncableEntity {
AGENT = 'agent',
@@ -28,7 +28,7 @@ export const isSyncableEntity = (value: string): value is SyncableEntity => {
export class EntityAddCommand {
async execute(entityType?: SyncableEntity, path?: string): Promise<void> {
try {
// Default to src/app/ folder, allow override with path parameter
// Default to src/ folder, allow override with path parameter
const appPath = path
? join(CURRENT_EXECUTION_DIRECTORY, path)
: join(CURRENT_EXECUTION_DIRECTORY, APP_FOLDER);
@@ -1,12 +1,8 @@
export const computeFrontComponentOutputPath = (
componentPath: string,
): string => {
export const computeFrontComponentOutputPath = (componentPath: string): string => {
const normalizedPath = componentPath.replace(/\\/g, '/');
let relativePath = normalizedPath;
if (relativePath.startsWith('src/app/')) {
relativePath = relativePath.slice('src/app/'.length);
} else if (relativePath.startsWith('src/')) {
if (relativePath.startsWith('src/')) {
relativePath = relativePath.slice('src/'.length);
}
@@ -1,32 +1,26 @@
import { computeFunctionOutputPath } from '../function-paths';
describe('computeFunctionOutputPath', () => {
it('should handle function in src/app root', () => {
const result = computeFunctionOutputPath('src/app/hello.function.ts');
it('should handle function in src/ root', () => {
const result = computeFunctionOutputPath('src/hello.function.ts');
expect(result).toBe('hello.function.js');
});
it('should handle function in subdirectory', () => {
const result = computeFunctionOutputPath('src/app/utils/greet.function.ts');
const result = computeFunctionOutputPath('src/utils/greet.function.ts');
expect(result).toBe('utils/greet.function.js');
});
it('should handle deeply nested function', () => {
const result = computeFunctionOutputPath(
'src/app/modules/auth/handlers/login.function.ts',
'src/modules/auth/handlers/login.function.ts',
);
expect(result).toBe('modules/auth/handlers/login.function.js');
});
it('should handle src/ prefix without app/', () => {
const result = computeFunctionOutputPath('src/handlers/process.function.ts');
expect(result).toBe('handlers/process.function.js');
});
it('should handle path without src/ prefix', () => {
const result = computeFunctionOutputPath('handlers/webhook.function.ts');
@@ -34,13 +28,13 @@ describe('computeFunctionOutputPath', () => {
});
it('should normalize Windows path separators', () => {
const result = computeFunctionOutputPath('src\\app\\utils\\greet.function.ts');
const result = computeFunctionOutputPath('src\\utils\\greet.function.ts');
expect(result).toBe('utils/greet.function.js');
});
it('should change .ts extension to .js', () => {
const result = computeFunctionOutputPath('src/app/test.function.ts');
const result = computeFunctionOutputPath('src/test.function.ts');
expect(result.endsWith('.js')).toBe(true);
expect(result.endsWith('.ts')).toBe(false);
@@ -1,12 +1,8 @@
export const computeFunctionOutputPath = (
handlerPath: string,
): string => {
export const computeFunctionOutputPath = (handlerPath: string): string => {
const normalizedPath = handlerPath.replace(/\\/g, '/');
let relativePath = normalizedPath;
if (relativePath.startsWith('src/app/')) {
relativePath = relativePath.slice('src/app/'.length);
} else if (relativePath.startsWith('src/')) {
if (relativePath.startsWith('src/')) {
relativePath = relativePath.slice('src/'.length);
}
@@ -1,208 +0,0 @@
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/test-app/src/app/default-function.role';
import {
POST_CARD_EXTENSION_CATEGORY_FIELD_ID,
POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
} from '@/cli/__tests__/test-app/src/app/postCard.object-extension';
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
import { join } from 'path';
import { type ApplicationManifest } from 'twenty-shared/application';
const TEST_APP_PATH = join(__dirname, '../../../../__tests__/test-app');
describe('runManifestBuild with test-app', () => {
let manifest: ApplicationManifest;
beforeAll(async () => {
const result = await runManifestBuild(TEST_APP_PATH, { display: false, writeOutput: false });
if (!result) {
throw new Error('Failed to build manifest');
}
manifest = result;
}, 15_000);
it('should load manifest from test-app directory', async () => {
expect(manifest.packageJson.name).toBe('test-app');
expect(manifest.packageJson.version).toBe('0.0.1');
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');
expect(postCard.fields).toHaveLength(5);
const contentField = postCard.fields?.find(
(field: { name: string }) => field.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(
(field: { name: string }) => field.name === 'status',
);
expect(statusField).toBeDefined();
expect(statusField?.type).toBe('SELECT');
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');
expect(testFunction.triggers).toHaveLength(3);
const routeTrigger = testFunction.triggers.find(
(trigger: { type: string }) => trigger.type === 'route',
) as any;
expect(routeTrigger).toBeDefined();
expect(routeTrigger?.universalIdentifier).toBe(
'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
);
expect(routeTrigger?.path).toBe('/post-card/create');
expect(routeTrigger?.httpMethod).toBe('GET');
expect(routeTrigger?.forwardedRequestHeaders).toEqual(['signature']);
const cronTrigger = testFunction.triggers.find(
(trigger: { type: string }) => trigger.type === 'cron',
) as any;
expect(cronTrigger).toBeDefined();
expect(cronTrigger?.pattern).toBe('0 0 1 1 *');
const dbEventTrigger = testFunction.triggers.find(
(trigger: { type: string }) => trigger.type === 'databaseEvent',
) as any;
expect(dbEventTrigger).toBeDefined();
expect(dbEventTrigger?.eventName).toBe('person.created');
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);
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);
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();
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();
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(appSources['postCard.object-extension.ts']).toContain(
'extendObject',
);
expect(manifest.objectExtensions).toBeDefined();
expect(manifest.objectExtensions).toHaveLength(1);
const postCardExtension = manifest.objectExtensions![0];
expect(postCardExtension.targetObject.nameSingular).toBe('postCard');
expect(postCardExtension.fields).toHaveLength(2);
const priorityField = postCardExtension.fields.find(
(field: { name: string }) => field.name === 'priority',
);
expect(priorityField).toBeDefined();
expect(priorityField?.universalIdentifier).toBe(
POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
);
expect(priorityField?.type).toBe('NUMBER');
expect(priorityField?.label).toBe('Priority');
expect(priorityField?.description).toBe(
'Priority level for the post card (1-10)',
);
const categoryField = postCardExtension.fields.find(
(field: { name: string }) => field.name === 'category',
);
expect(categoryField).toBeDefined();
expect(categoryField?.universalIdentifier).toBe(
POST_CARD_EXTENSION_CATEGORY_FIELD_ID,
);
expect(categoryField?.type).toBe('SELECT');
expect(categoryField?.label).toBe('Category');
expect((categoryField as any)?.options).toHaveLength(3);
const expectedRoleId = DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER;
expect(manifest.application.functionRoleUniversalIdentifier).toBe(
expectedRoleId,
);
const linkedRole = manifest.roles?.find(
(r: { universalIdentifier: string }) =>
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);
});
});
@@ -13,7 +13,7 @@ export class ApplicationEntityBuilder
implements ManifestEntityBuilder<Application>
{
async build(appPath: string): Promise<Application> {
const applicationConfigPath = path.join(appPath, 'src', 'app', 'application.config.ts');
const applicationConfigPath = path.join(appPath, 'src', 'application.config.ts');
return manifestExtractFromFileServer.extractManifestFromFile<Application>(applicationConfigPath);
}
@@ -14,7 +14,7 @@ export class FrontComponentEntityBuilder
implements ManifestEntityBuilder<FrontComponentManifest[]>
{
async build(appPath: string): Promise<FrontComponentManifest[]> {
const componentFiles = await glob(['src/app/**/*.front-component.tsx'], {
const componentFiles = await glob(['src/**/*.front-component.tsx'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
@@ -14,7 +14,7 @@ export class FunctionEntityBuilder
implements ManifestEntityBuilder<ServerlessFunctionManifest[]>
{
async build(appPath: string): Promise<ServerlessFunctionManifest[]> {
const functionFiles = await glob(['src/app/**/*.function.ts'], {
const functionFiles = await glob(['src/**/*.function.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
@@ -15,7 +15,7 @@ export class ObjectExtensionEntityBuilder
implements ManifestEntityBuilder<ObjectExtensionManifest[]>
{
async build(appPath: string): Promise<ObjectExtensionManifest[]> {
const extensionFiles = await glob(['src/app/**/*.object-extension.ts'], {
const extensionFiles = await glob(['src/**/*.object-extension.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
@@ -16,7 +16,7 @@ export class ObjectEntityBuilder
implements ManifestEntityBuilder<ObjectManifest[]>
{
async build(appPath: string): Promise<ObjectManifest[]> {
const objectFiles = await glob(['src/app/**/*.object.ts'], {
const objectFiles = await glob(['src/**/*.object.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
@@ -5,14 +5,14 @@ import { type RoleManifest } from 'twenty-shared/application';
import { manifestExtractFromFileServer } from '../manifest-extract-from-file-server';
import { type ValidationError } from '../manifest.types';
import {
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from './entity.interface';
export class RoleEntityBuilder implements ManifestEntityBuilder<RoleManifest[]> {
async build(appPath: string): Promise<RoleManifest[]> {
const roleFiles = await glob(['src/app/**/*.role.ts'], {
const roleFiles = await glob(['src/**/*.role.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
@@ -19,25 +19,24 @@ import { validateManifest } from './manifest-validate';
import { ManifestValidationError } from './manifest.types';
const validateFolderStructure = async (appPath: string): Promise<void> => {
const appFolder = path.join(appPath, 'src', 'app');
const srcFolder = path.join(appPath, 'src');
if (!(await fs.pathExists(appFolder))) {
if (!(await fs.pathExists(srcFolder))) {
throw new Error(
`Missing src/app/ folder in ${appPath}.\n` +
'Create it with: mkdir -p src/app',
`Missing src/ folder in ${appPath}.\n` + 'Create it with: mkdir -p src',
);
}
const configFile = path.join(appPath, 'src', 'app', 'application.config.ts');
const configFile = path.join(appPath, 'src', 'application.config.ts');
if (!(await fs.pathExists(configFile))) {
throw new Error('Missing src/app/application.config.ts');
throw new Error('Missing src/application.config.ts');
}
};
const loadSources = async (appPath: string): Promise<Sources> => {
const sources: Sources = {};
const tsFiles = await glob(['src/**/*.ts', 'generated/**/*.ts'], {
const tsFiles = await glob(['src/**/*.ts', 'src/**/*.tsx', 'generated/**/*.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
@@ -51,13 +51,13 @@ export const validateManifest = (
if (!isNonEmptyArray(manifest.objects)) {
warnings.push({
message: 'No objects defined in src/app/objects/',
message: 'No objects defined in src/',
});
}
if (!isNonEmptyArray(manifest.serverlessFunctions)) {
warnings.push({
message: 'No functions defined in src/app/functions/',
message: 'No functions defined in src/',
});
}
+6
View File
@@ -13,8 +13,14 @@ export default defineConfig({
environment: 'node',
include: [
'src/**/__tests__/**/*.{test,spec}.{ts,tsx}',
'src/**/__integration__/**/*.{test,spec}.{ts,tsx}',
'src/**/*.{test,spec}.{ts,tsx}',
],
exclude: [
'**/node_modules/**',
'**/.git/**',
'**/__e2e__/**',
],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
@@ -0,0 +1,21 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
root: __dirname,
ignoreConfigErrors: true,
}),
],
test: {
name: 'twenty-sdk-integration',
environment: 'node',
include: ['src/**/__integration__/**/*.{test,spec}.{ts,tsx}'],
exclude: ['**/node_modules/**', '**/.git/**'],
globals: true,
diff: {
truncateThreshold: 0,
},
},
});
+36
View File
@@ -0,0 +1,36 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
root: __dirname,
ignoreConfigErrors: true,
}),
],
test: {
name: 'twenty-sdk-unit',
environment: 'node',
include: [
'src/**/__tests__/**/*.{test,spec}.{ts,tsx}',
'src/**/*.{test,spec}.{ts,tsx}',
],
exclude: [
'**/node_modules/**',
'**/.git/**',
'**/__e2e__/**',
'**/__integration__/**',
],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.d.ts', 'src/cli/cli.ts'],
thresholds: {
statements: 1,
lines: 1,
functions: 1,
},
},
globals: true,
},
});