e2e test environment fortwenty-cli (#15123)

# Introduction
Defining very first basis of the twenty-cli e2e testing env.
Dynamically generating tests cases based on a list of applications names
that will be matched to stored twenty-apps and run install delete and
reinstall with their configuration on the same instance

We could use a glob pattern with a specific e2e configuration in every
apps but right now overkill

## Notes
- We should define typescript path aliasing to ease import devxp
- parse the config using a zod object

## Some vision on test granularity
Right now we only check that the server sent back success or failure on
below operation. In the future the synchronize will return a report of
what has been installed per entity exactly. We will be able to snapshot
everything in order to detect regressions

We should also be testing the cli directly for init and other stuff in
the end, we could get some inspiration from what's done in preconstruct
e2e tests with an on heap virtual file system

## Conclusion
Any suggestions are more than welcomed !
close https://github.com/twentyhq/core-team-issues/issues/1721
This commit is contained in:
Paul Rastoin
2025-10-16 14:43:43 +02:00
committed by GitHub
parent 73399809f4
commit 7c661f47fd
21 changed files with 353 additions and 49 deletions
+43 -1
View File
@@ -20,6 +20,7 @@ jobs:
with:
files: |
packages/twenty-cli/**
packages/twenty-server/**
cli-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
@@ -44,11 +45,52 @@ jobs:
with:
tag: scope:cli
tasks: ${{ matrix.task }}
cli-e2e-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, cli-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/workflows/actions/yarn-install
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: CLI / Run E2E Tests
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:cli
tasks: 'test:e2e'
ci-cli-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, cli-test]
needs: [changed-files-check, cli-test, cli-e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
+4
View File
@@ -0,0 +1,4 @@
TWENTY_API_URL=http://localhost:3000
# APPLE_JANE_ADMIN_ACCESS_TOKEN
TWENTY_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
TWENTY_DEFAULT_APP=e2e-default-app
+48
View File
@@ -0,0 +1,48 @@
import { type JestConfigWithTsJest } from 'ts-jest';
const jestConfig: JestConfigWithTsJest = {
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
// Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
prettierPath: null,
displayName: 'twenty-cli-e2e',
silent: false,
errorOnDeprecated: true,
maxConcurrency: 1,
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testEnvironment: 'node',
testRegex: '\\.e2e-spec\\.ts$',
modulePathIgnorePatterns: ['<rootDir>/dist'],
globalTeardown: '<rootDir>/src/__tests__/e2e/teardown.ts',
testTimeout: 30000, // 30 seconds timeout for e2e tests
maxWorkers: 1,
transform: {
'^.+\\.(t|j)s$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: false,
decorators: true,
},
transform: {
decoratorMetadata: true,
},
baseUrl: '.',
paths: {
'src/*': ['./src/*'],
},
},
},
],
},
transformIgnorePatterns: [
'node_modules/(?!(chalk|inquirer|@inquirer|ansi-styles|strip-ansi|has-flag|supports-color|color-convert|color-name|wrap-ansi|string-width|is-fullwidth-code-point|emoji-regex|onetime|mimic-fn|signal-exit|yallist|lru-cache|p-limit|p-queue|p-timeout|p-finally|p-try|p-cancelable|p-locate|p-map|p-race|p-reduce|p-some|p-waterfall|p-defer|p-delay|p-retry|p-any|p-settle|p-all|p-map-series|p-map-concurrent|p-filter|p-reject|p-tap|p-log|p-debounce|p-throttle|p-forever|p-whilst|p-do-whilst|p-until|p-wait-for|p-min-delay)/)',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
export default jestConfig;
+7 -1
View File
@@ -6,6 +6,11 @@
"bin": {
"twenty": "dist/cli.js"
},
"files": [
"dist/**/*",
"!dist/**/*.e2e-spec.*",
"!dist/**/__tests__/**"
],
"scripts": {
"build": "echo 'use npx nx build'",
"dev": "tsx src/cli.ts",
@@ -40,7 +45,8 @@
"@types/node": "^20.0.0",
"jest": "^29.5.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
"typescript": "^5.3.0",
"wait-on": "^7.2.0"
},
"engines": {
"node": "^24.5.0",
+31 -4
View File
@@ -9,7 +9,7 @@
"cache": true,
"options": {
"cwd": "packages/twenty-cli",
"commands": ["rimraf dist", "tsc"]
"commands": ["rimraf dist", "tsc --project tsconfig.lib.json"]
},
"dependsOn": ["^after-build"]
},
@@ -18,7 +18,9 @@
"cache": true,
"options": {
"cwd": "packages/twenty-cli",
"commands": ["cp -R src/constants/base-application-project dist/constants"]
"commands": [
"cp -R src/constants/base-application-project dist/constants"
]
},
"dependsOn": ["after-build"]
},
@@ -42,7 +44,7 @@
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-cli",
"command": "tsc --noEmit"
"command": "tsc --noEmit --project tsconfig.lib.json"
}
},
"lint": {
@@ -51,7 +53,7 @@
"maxWarnings": 0
},
"configurations": {
"ci": {
"ci": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
@@ -71,6 +73,31 @@
"watchAll": false
}
}
},
"test:e2e": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"cwd": "packages/twenty-cli",
"commands": [
"npx wait-on http://localhost:3000/healthz --timeout 600000 --interval 1000 --verbose && NODE_ENV=test npx jest --config ./jest.e2e.config.ts"
]
},
"parallel": false,
"dependsOn": [
{
"target": "build",
"projects": "dependencies"
},
{
"target": "database:reset",
"projects": "twenty-server"
},
{
"target": "start:ci-if-needed",
"projects": "twenty-server"
}
]
}
}
}
@@ -0,0 +1,52 @@
import { existsSync } from 'fs';
import { AppDeleteCommand } from '../../commands/app-delete.command';
import { AppSyncCommand } from '../../commands/app-sync.command';
import { COVERED_APPLICATION_FOLDERS } from './constants/covered-applications-folder.constant';
import { getTestedApplicationPath } from './utils/get-tested-application-path.util';
describe.each(COVERED_APPLICATION_FOLDERS)(
'Application: "%s" install delete and reinstall test suite',
(applicationName) => {
const syncCommand = new AppSyncCommand();
const deleteCommand = new AppDeleteCommand();
const appPath = getTestedApplicationPath(applicationName);
beforeAll(async () => {
expect(existsSync(appPath)).toBe(true);
});
// TODO uncomment when reinstall is fix to cleanup gracefully
// afterAll(async () => {
// const result = await deleteCommand.execute({
// appPath,
// askForConfirmation: false,
// });
// expect(result.success).toBe(true);
// });
it(`should successfully install ${applicationName} application`, async () => {
const result = await syncCommand.execute(appPath);
expect(result.success).toBe(true);
});
it(`should successfully delete ${applicationName} application`, async () => {
const result = await deleteCommand.execute({
appPath,
askForConfirmation: false,
});
expect(result.success).toBe(true);
});
it.failing(
`should successfully re-install ${applicationName} application`,
async () => {
const result = await syncCommand.execute(appPath);
expect(result.success).toBe(true);
},
);
},
);
@@ -0,0 +1 @@
export const COVERED_APPLICATION_FOLDERS = ['hello-world'] as const;
@@ -0,0 +1 @@
export const SERVER_URL = 'http://localhost:3000';
@@ -0,0 +1,21 @@
import axios from 'axios';
import { ConfigService } from '../../services/config.service';
import { SERVER_URL } from './constants/server-url.constant';
describe('Twenty Server Health Check (E2E)', () => {
const configService = new ConfigService();
const HEALTH_ENDPOINT = `${SERVER_URL}/healthz`;
it('should assert e2e configuration is loaded by default', async () => {
const configuration = await configService.getConfig();
expect(configuration).toMatchObject({
defaultApp: 'e2e-default-app',
});
});
it('should return 200 for health', async () => {
const response = await axios.get(HEALTH_ENDPOINT);
expect(response.status).toBe(200);
expect(response.data).toBeDefined();
});
});
@@ -0,0 +1,14 @@
import { exec } from 'child_process';
export default async function globalTeardown() {
return new Promise<void>((resolve) => {
exec('pkill -f "nest start" || true', (error: unknown) => {
if (error) {
console.log('No server processes to kill');
} else {
console.log('✅ Server processes cleaned up');
}
resolve();
});
});
}
@@ -0,0 +1,12 @@
import path from 'path';
export const getTestedApplicationPath = (relativePath: string): string => {
const currentFileDir = __dirname;
const twentyAppsPath = path.resolve(
currentFileDir,
'../../../../../twenty-apps',
);
return path.join(twentyAppsPath, relativePath);
};
@@ -1,21 +1,26 @@
import inquirer from 'inquirer';
import chalk from 'chalk';
import { ApiService } from '../services/api.service';
import inquirer from 'inquirer';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/app-manifest-loader';
export class AppDeleteCommand {
private apiService = new ApiService();
async execute(): Promise<void> {
async execute({
appPath = CURRENT_EXECUTION_DIRECTORY,
askForConfirmation,
}: {
appPath?: string;
askForConfirmation: boolean;
}): Promise<ApiResponse<any>> {
try {
const appPath = CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('🚀 Deleting Twenty Application'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
console.log('');
if (!(await this.confirmationPrompt())) {
if (askForConfirmation && !(await this.confirmationPrompt())) {
console.error(chalk.red('⛔️ Aborting deletion'));
process.exit(1);
}
@@ -26,16 +31,17 @@ export class AppDeleteCommand {
if (!result.success) {
console.error(chalk.red('❌ Deletion failed:'), result.error);
process.exit(1);
} else {
console.log(chalk.green('✅ Application deleted successfully'));
}
console.log(chalk.green('✅ Application deleted successfully'));
return result;
} catch (error) {
console.error(
chalk.red('Deletion failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
throw error;
}
}
@@ -1,15 +1,17 @@
import chalk from 'chalk';
import { ApiService } from '../services/api.service';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { ApiService } from '../services/api.service';
import { ApiResponse } from '../types/config.types';
import { loadManifest } from '../utils/app-manifest-loader';
export class AppSyncCommand {
private apiService = new ApiService();
async execute(): Promise<void> {
// TODO improve typing
async execute(
appPath: string = CURRENT_EXECUTION_DIRECTORY,
): Promise<ApiResponse<any>> {
try {
const appPath = CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('🚀 Syncing Twenty Application'));
console.log(chalk.gray(`📁 App Path: ${appPath}`));
console.log('');
@@ -24,16 +26,17 @@ export class AppSyncCommand {
if (!result.success) {
console.error(chalk.red('❌ Sync failed:'), result.error);
process.exit(1);
} else {
console.log(chalk.green('✅ Application synced successfully'));
}
console.log(chalk.green('✅ Application synced successfully'));
return result;
} catch (error) {
console.error(
chalk.red('Sync failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
throw error;
}
}
}
@@ -1,14 +1,14 @@
import chalk from 'chalk';
import { Command } from 'commander';
import { AppSyncCommand } from './app-sync.command';
import { AppDevCommand } from './app-dev.command';
import { AppInitCommand } from './app-init.command';
import { AppDeleteCommand } from './app-delete.command';
import {
AppAddCommand,
isSyncableEntity,
SyncableEntity,
} from './app-add.command';
import chalk from 'chalk';
import { AppDeleteCommand } from './app-delete.command';
import { AppDevCommand } from './app-dev.command';
import { AppInitCommand } from './app-init.command';
import { AppSyncCommand } from './app-sync.command';
export class AppCommand {
private devCommand = new AppDevCommand();
@@ -33,14 +33,30 @@ export class AppCommand {
.command('sync')
.description('Sync application to Twenty')
.action(async () => {
await this.syncCommand.execute();
try {
const result = await this.syncCommand.execute();
if (!result.success) {
process.exit(1);
}
} catch {
process.exit(1);
}
});
appCommand
.command('delete')
.description('Delete application from Twenty')
.action(async () => {
await this.deleteCommand.execute();
try {
const result = await this.deleteCommand.execute({
askForConfirmation: true,
});
if (!result.success) {
process.exit(1);
}
} catch {
process.exit(1);
}
});
appCommand
@@ -14,7 +14,10 @@ export class ConfigService {
private loadEnvironmentVariables(): void {
// Load local .env file if it exists in current working directory
const localEnvPath = path.join(process.cwd(), '.env');
const localEnvPath = path.join(
process.cwd(),
process.env.NODE_ENV === 'test' ? '.env.e2e' : '.env',
);
if (fs.existsSync(localEnvPath)) {
loadDotenv({ path: localEnvPath });
}
@@ -38,6 +41,7 @@ export class ConfigService {
if (configExists) {
const configContent = await fs.readFile(this.configPath, 'utf8');
// TODO parse using a zod schema
fileConfig = JSON.parse(configContent || '{}');
}
@@ -6,8 +6,8 @@ import {
CoreEntityManifest,
PackageJson,
} from '../types/config.types';
import { parseJsoncFile } from './jsonc-parser';
import { validateSchema } from '../utils/schema-validator';
import { parseJsoncFile } from './jsonc-parser';
type Sources = { [key: string]: string | Sources };
+31
View File
@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist-e2e",
"rootDir": "./src",
"module": "commonjs",
"target": "ES2022",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"types": ["jest", "node"]
},
"include": [
"src/**/*",
"src/**/__tests__/**/*.e2e-spec.ts"
],
"exclude": [
"node_modules",
"dist",
"dist-e2e",
"**/*.test.ts",
"**/*.spec.ts"
]
}
+4 -17
View File
@@ -1,21 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "commonjs",
"target": "ES2022",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.e2e-spec.ts", "**/__tests__/**"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "commonjs",
"target": "ES2022",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.e2e-spec.ts", "**/__tests__/**"]
}
+7
View File
@@ -57,6 +57,13 @@
"command": "NODE_ENV=development && nest start"
}
},
"start:ci-if-needed": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "curl -f -s http://localhost:3000/healthz > /dev/null 2>&1 && echo '✅ Server already running' || (echo '🚀 Server not running, starting...' && NODE_ENV=development nohup nest start > server.log 2>&1 &)"
}
},
"start:debug": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
+2 -1
View File
@@ -52806,6 +52806,7 @@ __metadata:
lodash.kebabcase: "npm:^4.1.1"
tsx: "npm:^4.7.0"
typescript: "npm:^5.3.0"
wait-on: "npm:^7.2.0"
bin:
twenty: dist/cli.js
languageName: unknown
@@ -55410,7 +55411,7 @@ __metadata:
languageName: node
linkType: hard
"wait-on@npm:^7.0.0":
"wait-on@npm:^7.0.0, wait-on@npm:^7.2.0":
version: 7.2.0
resolution: "wait-on@npm:7.2.0"
dependencies: