Fix sdk-e2e-test: ensure DB is ready before server starts (#19583)
## Summary
- The `sdk-e2e-test` CI job has been failing since at least April 9th
because the nx dependency graph runs `database:reset` and
`start:ci-if-needed` in **parallel** — neither depends on the other. The
server starts before the DB tables are created, crashes with `relation
"core.keyValuePair" does not exist`, and `wait-on` times out after 10
minutes.
- Replace the `nx-affected` orchestration for e2e with explicit
**sequential** CI steps: build → create DB → reset DB → start server →
wait for health → run tests.
- Add server log dump on failure for easier future debugging.
## Root cause
In `packages/twenty-sdk/project.json`, the `test:e2e` target has:
```json
"dependsOn": [
"build",
{ "target": "database:reset", "projects": "twenty-server" },
{ "target": "start:ci-if-needed", "projects": "twenty-server" }
]
```
Since `database:reset` and `start:ci-if-needed` don't depend on each
other, nx can (and does) run them concurrently. `start:ci-if-needed`
fires `nohup nest start &` and immediately completes. The server process
tries to query `core.keyValuePair` before `database:reset` creates it →
crash → wait-on timeout → job failure.
This commit is contained in:
@@ -215,6 +215,7 @@ export class DevModeOrchestrator {
|
||||
if (objectsOrFieldsChanged) {
|
||||
await this.generateApiClientStep.execute({
|
||||
appPath: this.state.appPath,
|
||||
credentials: this.registerAppStep.registrationCredentials,
|
||||
});
|
||||
|
||||
this.skipTypecheck = false;
|
||||
@@ -232,7 +233,7 @@ export class DevModeOrchestrator {
|
||||
if (!createResult.success || !createResult.data) {
|
||||
this.state.applyStepEvents([
|
||||
{
|
||||
message: 'Failed to create development application',
|
||||
message: 'Failed to install development application',
|
||||
status: 'error',
|
||||
},
|
||||
{ message: JSON.stringify(createResult, null, 2), status: 'error' },
|
||||
@@ -249,7 +250,7 @@ export class DevModeOrchestrator {
|
||||
this.state.steps.resolveApplication.status = 'done';
|
||||
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'Application created', status: 'success' },
|
||||
{ message: 'Application installed', status: 'success' },
|
||||
]);
|
||||
|
||||
this.uploadFilesStep.initialize({
|
||||
|
||||
+7
-3
@@ -1,4 +1,4 @@
|
||||
import { ensureValidAppAccessTokenOrRefresh } from '@/cli/utilities/auth/resolve-app-access-token';
|
||||
import { ensureAppAccessTokenIsValidOrRefresh } from '@/cli/utilities/auth';
|
||||
import { type ClientService } from '@/cli/utilities/client/client-service';
|
||||
import { type ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
@@ -26,15 +26,19 @@ export class GenerateApiClientOrchestratorStep {
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
async execute(input: { appPath: string }): Promise<void> {
|
||||
async execute(input: {
|
||||
appPath: string;
|
||||
credentials?: { clientId: string; clientSecret: string };
|
||||
}): Promise<void> {
|
||||
const step = this.state.steps.generateApiClient;
|
||||
|
||||
step.status = 'in_progress';
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
const appAccessToken = await ensureValidAppAccessTokenOrRefresh(
|
||||
const appAccessToken = await ensureAppAccessTokenIsValidOrRefresh(
|
||||
this.configService,
|
||||
input.credentials,
|
||||
);
|
||||
|
||||
await this.clientService.generateCoreClient({
|
||||
|
||||
+41
-51
@@ -1,8 +1,5 @@
|
||||
import { type ApiService } from '@/cli/utilities/api/api-service';
|
||||
import {
|
||||
ensureValidAppAccessTokenOrRefresh,
|
||||
exchangeCredentialsForTokens,
|
||||
} from '@/cli/utilities/auth/resolve-app-access-token';
|
||||
import { ensureAppRegistration } from '@/cli/utilities/auth';
|
||||
import { type ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
@@ -13,6 +10,10 @@ export class RegisterAppOrchestratorStep {
|
||||
private state: OrchestratorState;
|
||||
private notify: () => void;
|
||||
|
||||
registrationCredentials:
|
||||
| { clientId: string; clientSecret: string }
|
||||
| undefined;
|
||||
|
||||
constructor({
|
||||
apiService,
|
||||
configService,
|
||||
@@ -31,59 +32,48 @@ export class RegisterAppOrchestratorStep {
|
||||
}
|
||||
|
||||
async execute(input: { manifest: Manifest }): Promise<void> {
|
||||
const existingToken = await ensureValidAppAccessTokenOrRefresh(
|
||||
this.configService,
|
||||
);
|
||||
try {
|
||||
const reg = await ensureAppRegistration(
|
||||
this.apiService,
|
||||
this.configService,
|
||||
{
|
||||
name: input.manifest.application.displayName,
|
||||
universalIdentifier: input.manifest.application.universalIdentifier,
|
||||
},
|
||||
);
|
||||
|
||||
this.registrationCredentials = {
|
||||
clientId: reg.clientId,
|
||||
clientSecret: reg.clientSecret,
|
||||
};
|
||||
|
||||
if (existingToken) {
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'App registration found in config', status: 'info' },
|
||||
{
|
||||
message: reg.isNewRegistration
|
||||
? `App registration created: ${input.manifest.application.displayName}`
|
||||
: 'Existing app registration found',
|
||||
status: reg.isNewRegistration ? 'success' : 'info',
|
||||
},
|
||||
...(reg.isNewRegistration
|
||||
? [
|
||||
{
|
||||
message: 'Credentials saved to config.' as const,
|
||||
status: 'info' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
} catch (error) {
|
||||
this.state.applyStepEvents([
|
||||
{
|
||||
message: `Failed to register app: ${error instanceof Error ? error.message : String(error)}`,
|
||||
status: 'error',
|
||||
},
|
||||
]);
|
||||
this.notify();
|
||||
|
||||
return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const createResult = await this.apiService.createApplicationRegistration({
|
||||
name: input.manifest.application.displayName,
|
||||
universalIdentifier: input.manifest.application.universalIdentifier,
|
||||
});
|
||||
|
||||
if (!createResult.success || !createResult.data) {
|
||||
this.state.applyStepEvents([
|
||||
{ message: 'Failed to create app registration', status: 'warning' },
|
||||
]);
|
||||
this.notify();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { applicationRegistration, clientSecret } = createResult.data;
|
||||
|
||||
await this.configService.setConfig({
|
||||
appRegistrationId: applicationRegistration.id,
|
||||
appRegistrationClientId: applicationRegistration.oAuthClientId,
|
||||
});
|
||||
|
||||
await exchangeCredentialsForTokens(this.configService, {
|
||||
clientId: applicationRegistration.oAuthClientId,
|
||||
clientSecret,
|
||||
});
|
||||
|
||||
this.state.applyStepEvents([
|
||||
{
|
||||
message: `App registration created: ${input.manifest.application.displayName}`,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
message: `Client ID: ${applicationRegistration.oAuthClientId}`,
|
||||
status: 'info',
|
||||
},
|
||||
{
|
||||
message: 'Credentials saved to config.',
|
||||
status: 'info',
|
||||
},
|
||||
]);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import {
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { FileUploader } from '@/cli/utilities/file/file-uploader';
|
||||
import { type FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type UploadFilesOrchestratorStepOutput = {
|
||||
fileUploader: FileUploader | null;
|
||||
@@ -34,7 +35,7 @@ export class UploadFilesOrchestratorStep {
|
||||
}
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return this.state.steps.uploadFiles.output.fileUploader !== null;
|
||||
return isDefined(this.state.steps.uploadFiles.output.fileUploader);
|
||||
}
|
||||
|
||||
initialize(input: { appPath: string; universalIdentifier: string }): void {
|
||||
|
||||
Reference in New Issue
Block a user