Skip to content

Commit 25e68c6

Browse files
authored
test: Align proxy service helper and add local dev service stack (no-changelog) (#25467)
1 parent cd175dd commit 25e68c6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+619
-495
lines changed

packages/testing/containers/n8n-start-stack.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
#!/usr/bin/env tsx
2+
import { writeFileSync } from 'node:fs';
3+
import { resolve } from 'node:path';
24
import { parseArgs } from 'node:util';
35

46
import { DockerImageNotFoundError } from './docker-image-not-found-error';
57
import { BASE_PERFORMANCE_PLANS, isValidPerformancePlan } from './performance-plans';
8+
import { createServiceStack } from './service-stack';
69
import type { CloudflaredResult } from './services/cloudflared';
710
import type { KeycloakResult } from './services/keycloak';
811
import type { MailpitResult } from './services/mailpit';
912
import type { NgrokResult } from './services/ngrok';
13+
import { services as SERVICE_REGISTRY } from './services/registry';
1014
import type { TracingResult } from './services/tracing';
1115
import type { ServiceName } from './services/types';
1216
import type { VictoriaLogsResult } from './services/victoria-logs';
@@ -44,6 +48,8 @@ ${colors.yellow}Usage:${colors.reset}
4448
npm run stack [options]
4549
4650
${colors.yellow}Options:${colors.reset}
51+
--services-only Start services only (no n8n containers), write .env for local dev
52+
--services <list> Comma-separated services (e.g. postgres,redis,mailpit,proxy,kafka)
4753
--postgres Use PostgreSQL instead of SQLite
4854
--queue Enable queue mode (requires PostgreSQL)
4955
--source-control Enable source control (Git) container for testing
@@ -108,6 +114,11 @@ ${Object.keys(BASE_PERFORMANCE_PLANS)
108114
.map((name) => ` npm run stack --plan ${name}`)
109115
.join('\n')}
110116
117+
${colors.bright}# Services only (local dev — writes .env for pnpm start)${colors.reset}
118+
pnpm services --services postgres
119+
pnpm services --services postgres,redis
120+
pnpm services --services postgres,mailpit,proxy
121+
111122
${colors.bright}# Parallel instances${colors.reset}
112123
npm run stack --name test-1
113124
npm run stack --name test-2
@@ -127,8 +138,10 @@ async function main() {
127138
args: process.argv.slice(2),
128139
options: {
129140
help: { type: 'boolean', short: 'h' },
141+
'services-only': { type: 'boolean' },
130142
postgres: { type: 'boolean' },
131143
queue: { type: 'boolean' },
144+
services: { type: 'string' },
132145
'source-control': { type: 'boolean' },
133146
oidc: { type: 'boolean' },
134147
observability: { type: 'boolean' },
@@ -152,8 +165,20 @@ async function main() {
152165
process.exit(0);
153166
}
154167

168+
const servicesOnly = values['services-only'] ?? false;
169+
155170
// Build services array from CLI flags
171+
const validServiceNames = new Set(Object.keys(SERVICE_REGISTRY));
156172
const services: ServiceName[] = [];
173+
if (values.services) {
174+
for (const name of values.services.split(',').map((s) => s.trim())) {
175+
if (!validServiceNames.has(name)) {
176+
log.error(`Unknown service: '${name}'. Available: ${[...validServiceNames].join(', ')}`);
177+
process.exit(1);
178+
}
179+
services.push(name as ServiceName);
180+
}
181+
}
157182
if (values['source-control']) services.push('gitea');
158183
if (values.oidc) services.push('keycloak');
159184
if (values.observability) services.push('victoriaLogs', 'victoriaMetrics', 'vector');
@@ -167,7 +192,11 @@ async function main() {
167192
const config: N8NConfig = {
168193
postgres: values.postgres ?? false,
169194
services,
170-
projectName: values.name ?? `n8n-stack-${Math.random().toString(36).substring(7)}`,
195+
projectName:
196+
values.name ??
197+
(servicesOnly
198+
? `n8n-svc-${Math.random().toString(36).substring(7)}`
199+
: `n8n-stack-${Math.random().toString(36).substring(7)}`),
171200
};
172201

173202
// Handle queue mode (mains > 1 or workers > 0)
@@ -227,6 +256,94 @@ async function main() {
227256
}
228257
}
229258

259+
// Services-only mode: start containers, write .env, no n8n
260+
if (servicesOnly) {
261+
if (services.length === 0) {
262+
log.error('No services specified. Use flags like --postgres, --redis, --mailpit, etc.');
263+
process.exit(1);
264+
}
265+
266+
log.header('Starting service containers');
267+
log.info(`Project: ${config.projectName}`);
268+
log.info(`Services: ${services.join(', ')}`);
269+
270+
try {
271+
const stack = await createServiceStack({
272+
services,
273+
projectName: config.projectName,
274+
});
275+
276+
// Collect host-compatible env vars from each service
277+
const envVars: Record<string, string> = {};
278+
for (const name of services) {
279+
const result = stack.serviceResults[name];
280+
if (!result) continue;
281+
282+
const service = SERVICE_REGISTRY[name];
283+
Object.assign(
284+
envVars,
285+
service.env?.(result, true) ?? {},
286+
service.extraEnv?.(result, true) ?? {},
287+
);
288+
}
289+
290+
// Write .env to packages/cli/bin/ because `pnpm start` runs os-normalize.mjs
291+
// which does `cd packages/cli/bin` before launching n8n, and dotenv loads from cwd.
292+
if (Object.keys(envVars).length > 0) {
293+
const repoRoot = resolve(__dirname, '../../..');
294+
const envPath = resolve(repoRoot, 'packages/cli/bin/.env');
295+
const lines = [
296+
'# Generated by pnpm services — do not edit',
297+
`# Project: ${stack.projectName}`,
298+
'# Stop with: pnpm --filter n8n-containers services:clean',
299+
'',
300+
...Object.entries(envVars).map(([key, value]) => `${key}=${value}`),
301+
'',
302+
];
303+
writeFileSync(envPath, lines.join('\n'));
304+
log.success(`Wrote ${Object.keys(envVars).length} env vars to packages/cli/bin/.env`);
305+
}
306+
307+
// Print summary
308+
log.header('Services running');
309+
for (const name of services) {
310+
const result = stack.serviceResults[name];
311+
if (!result) continue;
312+
313+
const service = SERVICE_REGISTRY[name];
314+
const vars = {
315+
...(service.env?.(result, true) ?? {}),
316+
...(service.extraEnv?.(result, true) ?? {}),
317+
};
318+
const varSummary = Object.entries(vars)
319+
.map(([k, v]) => `${k}=${v}`)
320+
.join(', ');
321+
log.success(`${name}${varSummary ? `: ${varSummary}` : ''}`);
322+
}
323+
324+
// Print mailpit UI URL if running
325+
const mailpitResult = stack.serviceResults.mailpit as MailpitResult | undefined;
326+
if (mailpitResult) {
327+
console.log('');
328+
log.info(`Mailpit UI: ${colors.cyan}${mailpitResult.meta.apiBaseUrl}${colors.reset}`);
329+
}
330+
331+
console.log('');
332+
log.info('Containers are running in the background');
333+
log.info(`Run ${colors.bright}pnpm dev${colors.reset} in another terminal to start n8n`);
334+
log.info(
335+
`Cleanup: ${colors.bright}pnpm --filter n8n-containers services:clean${colors.reset}`,
336+
);
337+
console.log('');
338+
} catch (error) {
339+
log.error(
340+
`Failed to start services: ${error instanceof Error ? error.message : String(error)}`,
341+
);
342+
process.exit(1);
343+
}
344+
return;
345+
}
346+
230347
log.header('Starting n8n Stack');
231348
log.info(`Project name: ${config.projectName}`);
232349
displayConfig(config);

packages/testing/containers/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"stack:clean:containers": "docker ps -aq --filter 'name=n8n-stack-*' | xargs -r docker rm -f 2>/dev/null",
1919
"stack:clean:networks": "docker network ls --filter 'label=org.testcontainers=true' -q | xargs -r docker network rm 2>/dev/null",
2020
"stack:clean:all": "pnpm run stack:clean:containers && pnpm run stack:clean:networks",
21+
"services": "tsx ./n8n-start-stack.ts --services-only",
22+
"services:clean": "docker ps -aq --filter 'name=n8n-svc-*' | xargs docker rm -f 2>/dev/null; rm -f ../../../packages/cli/bin/.env",
2123
"lint": "eslint . --quiet",
2224
"lint:fix": "eslint . --fix"
2325
},
@@ -31,6 +33,7 @@
3133
"@testcontainers/postgresql": "^11.0.3",
3234
"@testcontainers/redis": "^11.0.3",
3335
"get-port": "^7.1.0",
36+
"mockserver-client": "^5.15.0",
3437
"kafkajs": "catalog:",
3538
"testcontainers": "^11.11.0"
3639
}

packages/testing/containers/services/gitea.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ export const gitea: Service<GiteaResult> = {
7676
}
7777
},
7878

79-
env(): Record<string, string> {
79+
env(result: GiteaResult, external?: boolean): Record<string, string> {
8080
return {
81-
N8N_SOURCECONTROL_HOST: `http://${HOSTNAME}:${HTTP_PORT}`,
81+
N8N_SOURCECONTROL_HOST: external ? result.meta.apiUrl : `http://${HOSTNAME}:${HTTP_PORT}`,
8282
};
8383
},
8484
};

packages/testing/containers/services/kafka.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,11 @@ export const kafka: Service<KafkaResult> = {
4141
};
4242
},
4343

44-
env(): Record<string, string> {
45-
return {};
44+
env(result: KafkaResult, external?: boolean): Record<string, string> {
45+
if (!external) return {};
46+
return {
47+
KAFKA_BROKER: result.meta.externalBroker,
48+
};
4649
},
4750
};
4851

packages/testing/containers/services/keycloak.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,14 @@ export const keycloak: Service<KeycloakResult> = {
306306
}
307307
},
308308

309-
env(): Record<string, string> {
309+
env(result: KeycloakResult, external?: boolean): Record<string, string> {
310+
if (external) {
311+
return {
312+
N8N_OIDC_DISCOVERY_URL: result.meta.discoveryUrl,
313+
N8N_OIDC_CLIENT_ID: result.meta.clientId,
314+
N8N_OIDC_CLIENT_SECRET: result.meta.clientSecret,
315+
};
316+
}
310317
return {
311318
NODE_EXTRA_CA_CERTS: N8N_KEYCLOAK_CERT_PATH,
312319
NO_PROXY: `localhost,127.0.0.1,${HOSTNAME},host.docker.internal`,

packages/testing/containers/services/localstack.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,9 @@ export const localstack: Service<LocalStackResult> = {
8484
}
8585
},
8686

87-
env(result: LocalStackResult): Record<string, string> {
87+
env(result: LocalStackResult, external?: boolean): Record<string, string> {
8888
return {
89-
// AWS SDK v3 standard endpoint override
90-
AWS_ENDPOINT_URL: result.meta.internalEndpoint,
91-
// Dummy credentials (LocalStack doesn't validate by default)
89+
AWS_ENDPOINT_URL: external ? result.meta.endpoint : result.meta.internalEndpoint,
9290
AWS_ACCESS_KEY_ID: 'test',
9391
AWS_SECRET_ACCESS_KEY: 'test',
9492
AWS_DEFAULT_REGION: DEFAULT_REGION,

packages/testing/containers/services/mailpit.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,13 @@ export const mailpit: Service<MailpitResult> = {
114114
}
115115
},
116116

117-
env(): Record<string, string> {
117+
env(result: MailpitResult, external?: boolean): Record<string, string> {
118118
return {
119119
N8N_EMAIL_MODE: 'smtp',
120-
N8N_SMTP_HOST: HOSTNAME,
121-
N8N_SMTP_PORT: String(SMTP_PORT),
120+
N8N_SMTP_HOST: external ? result.container.getHost() : HOSTNAME,
121+
N8N_SMTP_PORT: external
122+
? String(result.container.getMappedPort(SMTP_PORT))
123+
: String(SMTP_PORT),
122124
N8N_SMTP_SSL: 'false',
123125
N8N_SMTP_SENDER: 'test@n8n.local',
124126
};
@@ -128,8 +130,16 @@ export const mailpit: Service<MailpitResult> = {
128130
export class MailpitHelper {
129131
private readonly apiBaseUrl: string;
130132

131-
constructor(apiBaseUrl: string) {
133+
/** SMTP host that n8n should use to send email (internal hostname in container mode, localhost in local mode) */
134+
readonly smtpHost: string;
135+
136+
/** SMTP port that n8n should use to send email (1025 in container mode, mapped port in local mode) */
137+
readonly smtpPort: number;
138+
139+
constructor(apiBaseUrl: string, smtpHost = HOSTNAME, smtpPort = SMTP_PORT) {
132140
this.apiBaseUrl = apiBaseUrl;
141+
this.smtpHost = smtpHost;
142+
this.smtpPort = smtpPort;
133143
}
134144

135145
async clear(): Promise<void> {

packages/testing/containers/services/mysql.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,15 @@ export const mysqlService: Service<MySqlResult> = {
5454
};
5555
},
5656

57-
env(): Record<string, string> {
58-
return {};
57+
env(result: MySqlResult, external?: boolean): Record<string, string> {
58+
if (!external) return {};
59+
return {
60+
DB_TYPE: 'mysqldb',
61+
DB_MYSQLDB_HOST: result.meta.externalHost,
62+
DB_MYSQLDB_PORT: String(result.meta.externalPort),
63+
DB_MYSQLDB_DATABASE: result.meta.database,
64+
DB_MYSQLDB_USER: result.meta.username,
65+
DB_MYSQLDB_PASSWORD: result.meta.password,
66+
};
5967
},
6068
};

packages/testing/containers/services/postgres.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ export const postgres: Service<PostgresResult> = {
4545
};
4646
},
4747

48-
env(result: PostgresResult): Record<string, string> {
48+
env(result: PostgresResult, external?: boolean): Record<string, string> {
4949
return {
5050
DB_TYPE: 'postgresdb',
51-
DB_POSTGRESDB_HOST: HOSTNAME,
52-
DB_POSTGRESDB_PORT: '5432',
51+
DB_POSTGRESDB_HOST: external ? result.container.getHost() : HOSTNAME,
52+
DB_POSTGRESDB_PORT: external ? String(result.container.getMappedPort(5432)) : '5432',
5353
DB_POSTGRESDB_DATABASE: result.meta.database,
5454
DB_POSTGRESDB_USER: result.meta.username,
5555
DB_POSTGRESDB_PASSWORD: result.meta.password,

0 commit comments

Comments
 (0)