Fix ci + improvements (#17795)

as title
This commit is contained in:
martmull
2026-02-10 11:48:10 +01:00
committed by GitHub
parent ee0474e287
commit 52fe21c04b
26 changed files with 182 additions and 248 deletions
+3 -17
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test:unit, storybook:build, storybook:test]
task: [lint, typecheck, test:unit, storybook:build, storybook:test, test:integration]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -47,7 +47,7 @@ jobs:
with:
tag: scope:sdk
tasks: ${{ matrix.task }}
sdk-e2e-integration-test:
sdk-e2e-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
needs: [changed-files-check, sdk-test]
@@ -82,23 +82,9 @@ jobs:
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
- 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 integration tests
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: test:integration
- name: SDK / Run e2e Tests
uses: ./.github/actions/nx-affected
with:
@@ -108,7 +94,7 @@ jobs:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, sdk-test, sdk-e2e-integration-test]
needs: [changed-files-check, sdk-test, sdk-e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
+2 -14
View File
@@ -63,20 +63,8 @@
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-sdk",
"command": "npx wait-on http://localhost:3000/healthz --timeout 600000 --interval 1000 --log && npx vitest run --config vitest.integration.config.ts"
},
"parallel": false,
"dependsOn": [
"build",
{
"target": "database:reset",
"projects": "twenty-server"
},
{
"target": "start:ci-if-needed",
"projects": "twenty-server"
}
]
"command": "npx vitest run --config vitest.integration.config.ts"
}
},
"test:e2e": {
"executor": "nx:run-commands",
@@ -1,17 +1,19 @@
import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util';
import { runAppDevInProcess } from '@/cli/__tests__/integration/utils/run-app-dev-in-process.util';
import * as fs from 'fs-extra';
import { join } from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
const APP_PATH = join(__dirname, '..');
const MANIFEST_OUTPUT_PATH = join(APP_PATH, OUTPUT_DIR, 'ioi', 'manifest.json');
const MANIFEST_OUTPUT_PATH = join(APP_PATH, OUTPUT_DIR, 'manifest.json');
describe('invalid-app manifest', () => {
it('should fail to build manifest due to duplicate universalIdentifier', async () => {
const result = await runAppDev({ appPath: APP_PATH, timeout: 10000 });
const result = await runAppDevInProcess({
appPath: APP_PATH,
timeout: 10000,
});
expect(result.success).toBe(false);
expect(result.output).toContain('Duplicate universal identifiers');
const manifestExists = await fs.pathExists(MANIFEST_OUTPUT_PATH);
@@ -1,47 +1,52 @@
import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util';
import { OUTPUT_DIR } from 'twenty-shared/application';
import { AppUninstallCommand } from '@/cli/commands/app/app-uninstall';
import { existsSync } from 'fs';
import { join, resolve } from 'path';
import { inspect } from 'util';
import { runCliCommand } from '@/cli/__tests__/integration/utils/run-cli-command.util';
inspect.defaultOptions.depth = 10;
describe('Application: install delete and reinstall rich-app', () => {
const applicationName = 'rich-app';
const deleteCommand = new AppUninstallCommand();
const appPath = resolve(__dirname, '../');
beforeAll(async () => {
expect(existsSync(appPath)).toBe(true);
});
afterAll(async () => {
const result = await deleteCommand.execute({
appPath,
askForConfirmation: false,
const result = await runCliCommand({
command: 'auth:status',
args: [appPath],
timeout: 5_000,
waitForOutput: '✓ Valid',
});
expect(result.success).toBe(true);
});
it(`should successfully install ${applicationName} application`, async () => {
await runAppDev({ appPath });
await runCliCommand({
command: 'app:dev',
args: [appPath],
waitForOutput: '✓ Synced',
});
expect(existsSync(join(appPath, OUTPUT_DIR, 'manifest.json'))).toBe(true);
});
it(`should successfully delete ${applicationName} application`, async () => {
const result = await deleteCommand.execute({
appPath,
askForConfirmation: false,
await runCliCommand({
command: 'app:uninstall',
args: [appPath, '-y'],
waitForOutput: 'Application uninstalled successfully',
});
expect(result.success).toBe(true);
});
it(`should successfully re-install ${applicationName} application`, async () => {
await runAppDev({ appPath });
await runCliCommand({
command: 'app:dev',
args: [appPath],
waitForOutput: '✓ Synced',
});
expect(existsSync(join(appPath, OUTPUT_DIR, 'manifest.json'))).toBe(true);
});
@@ -1,25 +1,18 @@
import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util';
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
import { runAppDevInProcess } from '@/cli/__tests__/integration/utils/run-app-dev-in-process.util';
import { join } from 'path';
import { defineConsoleOutputTests } from './tests/console-output.tests';
import { defineManifestTests } from './tests/manifest.tests';
import { defineEntitiesTests } from './tests/entities.tests';
import { defineManifestTests } from './tests/manifest.tests';
const APP_PATH = join(__dirname, '../..');
describe('rich-app app:dev', () => {
let result: RunCliCommandResult;
beforeAll(async () => {
result = await runAppDev({ appPath: APP_PATH });
if (!result.success) {
console.log(result.output.slice(undefined, 20_000));
}
const result = await runAppDevInProcess({ appPath: APP_PATH });
expect(result.success).toBe(true);
}, 60000);
defineConsoleOutputTests(() => result);
defineManifestTests(APP_PATH);
defineEntitiesTests(APP_PATH);
});
@@ -1,8 +1,6 @@
import { join } from 'path';
import { runAppDev } from '@/cli/__tests__/integration/utils/run-app-dev.util';
import { type RunCliCommandResult } from '@/cli/__tests__/integration/utils/run-cli-command.util';
import { defineConsoleOutputTests } from './tests/console-output.tests';
import { runAppDevInProcess } from '@/cli/__tests__/integration/utils/run-app-dev-in-process.util';
import { defineFrontComponentsTests } from './tests/front-components.tests';
import { defineLogicFunctionsTests } from './tests/logic-functions.tests';
import { defineManifestTests } from './tests/manifest.tests';
@@ -10,17 +8,12 @@ import { defineManifestTests } from './tests/manifest.tests';
const APP_PATH = join(__dirname, '../..');
describe('root-app app:dev', () => {
let result: RunCliCommandResult;
beforeAll(async () => {
result = await runAppDev({ appPath: APP_PATH });
if (!result.success) {
console.log(result.output.slice(undefined, 20_000));
}
const result = await runAppDevInProcess({ appPath: APP_PATH });
expect(result.success).toBe(true);
}, 60000);
defineConsoleOutputTests(() => result);
defineManifestTests(APP_PATH);
defineLogicFunctionsTests(APP_PATH);
defineFrontComponentsTests(APP_PATH);
@@ -1,11 +1,19 @@
import { ConfigService } from '@/cli/utilities/config/config-service';
import { testConfig } from '@/cli/__tests__/constants/testConfig';
import { vi, beforeAll, afterAll } from 'vitest';
import { getConfigPath } from '@/cli/utilities/config/get-config-path';
import * as fs from 'fs-extra';
import * as path from 'path';
import { beforeAll } from 'vitest';
beforeAll(() => {
vi.spyOn(ConfigService.prototype, 'getConfig').mockResolvedValue(testConfig);
});
const testConfigPath = getConfigPath();
afterAll(() => {
vi.restoreAllMocks();
beforeAll(async () => {
await fs.ensureDir(path.dirname(testConfigPath));
const configFile = {
profiles: {
default: testConfig,
},
};
await fs.writeFile(testConfigPath, JSON.stringify(configFile, null, 2));
});
@@ -1,7 +1,7 @@
import { type TwentyConfig } from '@/cli/utilities/config/config-service';
export const testConfig: TwentyConfig = {
apiUrl: 'http://localhost:3000',
apiUrl: 'http://apple.localhost:3000',
apiKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
};
@@ -0,0 +1,37 @@
import { AppDevCommand } from '@/cli/commands/app/app-dev';
import * as fs from 'fs-extra';
import { join } from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
export type RunAppDevResult = {
success: boolean;
};
export const runAppDevInProcess = async (options: {
appPath: string;
timeout?: number;
}): Promise<RunAppDevResult> => {
const { appPath, timeout = 30_000 } = options;
const manifestPath = join(appPath, OUTPUT_DIR, 'manifest.json');
const command = new AppDevCommand();
await command.execute({ appPath });
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (await fs.pathExists(manifestPath)) {
// Small delay to let any pending writes finish
await new Promise((resolve) => setTimeout(resolve, 500));
await command.close();
return { success: true };
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
await command.close();
return { success: false };
};
@@ -1,22 +0,0 @@
import {
runCliCommand,
type RunCliCommandResult,
} from './run-cli-command.util';
export type RunAppDevOptions = {
appPath: string;
timeout?: number;
};
export const runAppDev = (
options: RunAppDevOptions,
): Promise<RunCliCommandResult> => {
const { appPath, timeout = 60_000 } = options;
return runCliCommand({
command: 'app:dev',
args: [appPath],
waitForOutput: ['✓ Synced'],
timeout,
});
};
@@ -23,7 +23,7 @@ export type RunCliCommandResult = {
export const runCliCommand = (
options: RunCliCommandOptions,
): Promise<RunCliCommandResult> => {
const { command, args = [], waitForOutput, timeout = 60_000 } = options;
const { command, args = [], waitForOutput, timeout = 30_000 } = options;
return new Promise((resolve) => {
// Run from CLI directory to use twenty-sdk's tsconfig paths
@@ -43,6 +43,7 @@ export const runCliCommand = (
let output = '';
const timeoutId = setTimeout(() => {
child.kill();
console.log(`RunCliCommand ${command} timeout after ${timeout / 1_000}s`);
resolve({ success: false, output });
}, timeout);
@@ -76,11 +77,7 @@ export const runCliCommand = (
child.on('close', (code) => {
clearTimeout(timeoutId);
if (waitForOutputs.length === 0) {
resolve({ success: code === 0, output });
} else {
resolve({ success: false, output });
}
resolve({ success: code === 0, output });
});
child.on('error', () => {
@@ -0,0 +1,33 @@
import { vi } from 'vitest';
const mockApiService = {
validateAuth: vi.fn().mockResolvedValue({ authValid: true, serverUp: true }),
checkApplicationExist: vi
.fn()
.mockResolvedValue({ success: true, data: false }),
createApplication: vi
.fn()
.mockResolvedValue({ success: true, data: { id: 'mock-id' } }),
syncApplication: vi.fn().mockResolvedValue({ success: true, data: true }),
uploadFile: vi.fn().mockResolvedValue({ success: true, data: true }),
};
vi.mock('@/cli/utilities/api/api-service', () => ({
ApiService: class {
validateAuth = mockApiService.validateAuth;
checkApplicationExist = mockApiService.checkApplicationExist;
createApplication = mockApiService.createApplication;
syncApplication = mockApiService.syncApplication;
uploadFile = mockApiService.uploadFile;
},
}));
vi.mock('@/cli/utilities/file/file-uploader', () => ({
FileUploader: class {
uploadFile = vi.fn().mockResolvedValue({ success: true, data: true });
},
}));
vi.mock('@/cli/utilities/dev/dev-ui', () => ({
renderDevUI: vi.fn().mockResolvedValue({ unmount: vi.fn() }),
}));
@@ -79,11 +79,12 @@ export const registerCommands = (program: Command): void => {
program
.command('app:uninstall [appPath]')
.description('Uninstall application from Twenty')
.action(async (appPath?: string) => {
.option('-y, --yes', 'Skip confirmation prompt')
.action(async (appPath?: string, options?: { yes?: boolean }) => {
try {
const result = await uninstallCommand.execute({
appPath: formatPath(appPath),
askForConfirmation: true,
askForConfirmation: !options?.yes,
});
process.exit(result.success ? 0 : 1);
} catch {
@@ -31,6 +31,18 @@ export class AppDevCommand {
private uiStateManager: DevUiStateManager | null = null;
private unmountUI: (() => void) | null = null;
async close(): Promise<void> {
this.unmountUI?.();
await Promise.all([
this.manifestWatcher?.close(),
this.logicFunctionsWatcher?.close(),
this.frontComponentsWatcher?.close(),
this.assetWatcher?.close(),
this.dependencyWatcher?.close(),
]);
}
async execute(options: AppDevOptions): Promise<void> {
this.appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
@@ -163,21 +175,9 @@ export class AppDevCommand {
}
private setupGracefulShutdown(): void {
const shutdown = async () => {
this.unmountUI?.();
const shutdown = () => void this.close().then(() => process.exit(0));
await Promise.all([
this.manifestWatcher?.close(),
this.logicFunctionsWatcher?.close(),
this.frontComponentsWatcher?.close(),
this.assetWatcher?.close(),
this.dependencyWatcher?.close(),
]);
process.exit(0);
};
process.on('SIGINT', () => void shutdown());
process.on('SIGTERM', () => void shutdown());
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
}
@@ -79,7 +79,7 @@ export class LogicFunctionExecuteCommand {
} else {
console.log(
chalk.yellow(
'No functions found for this application. Have you synced your app with `yarn app:sync`?',
'No functions found for this application. Have you synced your app with `yarn app:dev`?',
),
);
}
@@ -1,7 +1,8 @@
import * as fs from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
import { getConfigPath } from '@/cli/utilities/config/get-config-path';
export type TwentyConfig = {
apiUrl: string;
apiKey?: string;
@@ -19,7 +20,7 @@ export class ConfigService {
private static activeWorkspace = DEFAULT_WORKSPACE_NAME;
constructor() {
this.configPath = path.join(os.homedir(), '.twenty', 'config.json');
this.configPath = getConfigPath();
}
static setActiveWorkspace(name?: string) {
@@ -0,0 +1,12 @@
import * as os from 'os';
import * as path from 'path';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
export const getConfigPath = (): string => {
if (process.env.NODE_ENV === 'test') {
return path.join(TEST_CONFIG_DIR, 'config.json');
}
return path.join(os.homedir(), '.twenty', 'config.json');
};
@@ -18,6 +18,9 @@ export default defineConfig({
truncateThreshold: 0,
},
fileParallelism: false,
setupFiles: ['src/cli/__tests__/constants/setupTest.ts'],
setupFiles: [
'src/cli/__tests__/constants/setupTest.ts',
'src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts',
],
},
});
+1
View File
@@ -2,6 +2,7 @@ PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/test
REDIS_URL=redis://localhost:6379
APP_SECRET=replace_me_with_a_random_string
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string
SIGN_IN_PREFILLED=true
EXCEPTION_HANDLER_DRIVER=CONSOLE
TELEMETRY_ENABLED=false
+1 -1
View File
@@ -65,7 +65,7 @@
"dependsOn": ["build"],
"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...' && nohup nest start > server.log 2>&1 &)"
"command": "curl -f -s http://localhost:3000/healthz > /dev/null 2>&1 && echo '✅ Server already running' || (echo '🚀 Server not running, starting...' && nohup nest start &)"
}
},
"start:debug": {
@@ -1,12 +1,21 @@
import { promises as fs } from 'fs';
import { resolve, join } from 'path';
import { getExecutorFilePath } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/get-executor-file-path';
import { ASSET_PATH } from 'src/constants/assets-path';
const EXECUTOR_FILE_PATH = resolve(
__dirname,
join(
ASSET_PATH,
`engine/core-modules/logic-function/logic-function-drivers/constants/executor`,
),
);
export const copyExecutor = async (buildDirectory: string) => {
await fs.mkdir(buildDirectory, {
recursive: true,
});
await fs.cp(getExecutorFilePath(), buildDirectory, {
await fs.cp(EXECUTOR_FILE_PATH, buildDirectory, {
recursive: true,
});
};
@@ -1,12 +0,0 @@
import path from 'path';
import { ASSET_PATH } from 'src/constants/assets-path';
export const getExecutorFilePath = (): string => {
const baseTypescriptProjectPath = path.join(
ASSET_PATH,
`engine/core-modules/logic-function/logic-function-drivers/constants/executor`,
);
return path.resolve(__dirname, baseTypescriptProjectPath);
};
@@ -1,15 +1,6 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { IsObject, IsOptional, IsString } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
import {
CronTriggerSettings,
@@ -18,35 +9,10 @@ import {
} from 'twenty-shared/application';
import type { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { CreateDefaultLogicFunctionInput } from 'src/engine/metadata-modules/logic-function/dtos/create-default-logic-function.input';
@InputType()
export class CreateLogicFunctionInput {
@IsString()
@IsNotEmpty()
@Field()
name: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsNumber()
@Field({ nullable: true })
@Min(1)
@Max(900)
@IsOptional()
timeoutSeconds?: number;
@HideField()
applicationId: string;
@HideField()
universalIdentifier?: string;
@HideField()
id: string;
export class CreateLogicFunctionInput extends CreateDefaultLogicFunctionInput {
@HideField()
checksum: string;
@@ -62,16 +28,6 @@ export class CreateLogicFunctionInput {
@Field({ nullable: false })
builtHandlerPath: string;
@Field(() => graphqlTypeJson, { nullable: true })
@IsObject()
@IsOptional()
toolInputSchema?: object;
@IsBoolean()
@Field({ nullable: true })
@IsOptional()
isTool?: boolean;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
@@ -19,7 +19,7 @@ export const fromCreateLogicFunctionInputToFlatLogicFunction = ({
workspaceId,
ownerFlatApplication,
}: FromCreateLogicFunctionInputToFlatLogicFunctionArgs): FlatLogicFunction => {
const id = rawCreateLogicFunctionInput.id;
const id = rawCreateLogicFunctionInput.id ?? v4();
const currentDate = new Date();
const sourceHandlerPath = rawCreateLogicFunctionInput.sourceHandlerPath;
@@ -1,12 +0,0 @@
import { dirname } from 'path';
export const getLogicFunctionBaseFolderPath = (handlerPath: string): string => {
return dirname(dirname(handlerPath));
};
export const getRelativePathFromBase = (
handlerPath: string,
baseFolderPath: string,
): string => {
return handlerPath.replace(`${baseFolderPath}/`, '');
};
@@ -1,45 +0,0 @@
import fs from 'fs/promises';
import path from 'path';
import { ASSET_PATH } from 'src/constants/assets-path';
export type CodeStepSeedProjectFile = {
name: string;
path: string;
content: Buffer;
};
const getAllFiles = async (
rootDir: string,
dir: string = rootDir,
files: CodeStepSeedProjectFile[] = [],
): Promise<CodeStepSeedProjectFile[]> => {
const dirEntries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of dirEntries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await getAllFiles(rootDir, fullPath, files);
} else {
files.push({
path: path.relative(rootDir, dir),
name: entry.name,
content: await fs.readFile(fullPath),
});
}
}
return files;
};
export const getCodeStepSeedProjectFiles = async (): Promise<
CodeStepSeedProjectFile[]
> => {
const seedProjectPath = path.join(
ASSET_PATH,
'modules/workflow/workflow-builder/workflow-version-step/code-step/constants/seed-project',
);
return getAllFiles(seedProjectPath);
};