test: Align proxy service helper and add local dev service stack (no-changelog) (#25467)

This commit is contained in:
Declan Carroll
2026-02-10 15:03:18 +00:00
committed by GitHub
parent cd175ddda0
commit 25e68c6a3f
44 changed files with 619 additions and 495 deletions
+118 -1
View File
@@ -1,12 +1,16 @@
#!/usr/bin/env tsx
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';
import { DockerImageNotFoundError } from './docker-image-not-found-error';
import { BASE_PERFORMANCE_PLANS, isValidPerformancePlan } from './performance-plans';
import { createServiceStack } from './service-stack';
import type { CloudflaredResult } from './services/cloudflared';
import type { KeycloakResult } from './services/keycloak';
import type { MailpitResult } from './services/mailpit';
import type { NgrokResult } from './services/ngrok';
import { services as SERVICE_REGISTRY } from './services/registry';
import type { TracingResult } from './services/tracing';
import type { ServiceName } from './services/types';
import type { VictoriaLogsResult } from './services/victoria-logs';
@@ -44,6 +48,8 @@ ${colors.yellow}Usage:${colors.reset}
npm run stack [options]
${colors.yellow}Options:${colors.reset}
--services-only Start services only (no n8n containers), write .env for local dev
--services <list> Comma-separated services (e.g. postgres,redis,mailpit,proxy,kafka)
--postgres Use PostgreSQL instead of SQLite
--queue Enable queue mode (requires PostgreSQL)
--source-control Enable source control (Git) container for testing
@@ -108,6 +114,11 @@ ${Object.keys(BASE_PERFORMANCE_PLANS)
.map((name) => ` npm run stack --plan ${name}`)
.join('\n')}
${colors.bright}# Services only (local dev — writes .env for pnpm start)${colors.reset}
pnpm services --services postgres
pnpm services --services postgres,redis
pnpm services --services postgres,mailpit,proxy
${colors.bright}# Parallel instances${colors.reset}
npm run stack --name test-1
npm run stack --name test-2
@@ -127,8 +138,10 @@ async function main() {
args: process.argv.slice(2),
options: {
help: { type: 'boolean', short: 'h' },
'services-only': { type: 'boolean' },
postgres: { type: 'boolean' },
queue: { type: 'boolean' },
services: { type: 'string' },
'source-control': { type: 'boolean' },
oidc: { type: 'boolean' },
observability: { type: 'boolean' },
@@ -152,8 +165,20 @@ async function main() {
process.exit(0);
}
const servicesOnly = values['services-only'] ?? false;
// Build services array from CLI flags
const validServiceNames = new Set(Object.keys(SERVICE_REGISTRY));
const services: ServiceName[] = [];
if (values.services) {
for (const name of values.services.split(',').map((s) => s.trim())) {
if (!validServiceNames.has(name)) {
log.error(`Unknown service: '${name}'. Available: ${[...validServiceNames].join(', ')}`);
process.exit(1);
}
services.push(name as ServiceName);
}
}
if (values['source-control']) services.push('gitea');
if (values.oidc) services.push('keycloak');
if (values.observability) services.push('victoriaLogs', 'victoriaMetrics', 'vector');
@@ -167,7 +192,11 @@ async function main() {
const config: N8NConfig = {
postgres: values.postgres ?? false,
services,
projectName: values.name ?? `n8n-stack-${Math.random().toString(36).substring(7)}`,
projectName:
values.name ??
(servicesOnly
? `n8n-svc-${Math.random().toString(36).substring(7)}`
: `n8n-stack-${Math.random().toString(36).substring(7)}`),
};
// Handle queue mode (mains > 1 or workers > 0)
@@ -227,6 +256,94 @@ async function main() {
}
}
// Services-only mode: start containers, write .env, no n8n
if (servicesOnly) {
if (services.length === 0) {
log.error('No services specified. Use flags like --postgres, --redis, --mailpit, etc.');
process.exit(1);
}
log.header('Starting service containers');
log.info(`Project: ${config.projectName}`);
log.info(`Services: ${services.join(', ')}`);
try {
const stack = await createServiceStack({
services,
projectName: config.projectName,
});
// Collect host-compatible env vars from each service
const envVars: Record<string, string> = {};
for (const name of services) {
const result = stack.serviceResults[name];
if (!result) continue;
const service = SERVICE_REGISTRY[name];
Object.assign(
envVars,
service.env?.(result, true) ?? {},
service.extraEnv?.(result, true) ?? {},
);
}
// Write .env to packages/cli/bin/ because `pnpm start` runs os-normalize.mjs
// which does `cd packages/cli/bin` before launching n8n, and dotenv loads from cwd.
if (Object.keys(envVars).length > 0) {
const repoRoot = resolve(__dirname, '../../..');
const envPath = resolve(repoRoot, 'packages/cli/bin/.env');
const lines = [
'# Generated by pnpm services — do not edit',
`# Project: ${stack.projectName}`,
'# Stop with: pnpm --filter n8n-containers services:clean',
'',
...Object.entries(envVars).map(([key, value]) => `${key}=${value}`),
'',
];
writeFileSync(envPath, lines.join('\n'));
log.success(`Wrote ${Object.keys(envVars).length} env vars to packages/cli/bin/.env`);
}
// Print summary
log.header('Services running');
for (const name of services) {
const result = stack.serviceResults[name];
if (!result) continue;
const service = SERVICE_REGISTRY[name];
const vars = {
...(service.env?.(result, true) ?? {}),
...(service.extraEnv?.(result, true) ?? {}),
};
const varSummary = Object.entries(vars)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
log.success(`${name}${varSummary ? `: ${varSummary}` : ''}`);
}
// Print mailpit UI URL if running
const mailpitResult = stack.serviceResults.mailpit as MailpitResult | undefined;
if (mailpitResult) {
console.log('');
log.info(`Mailpit UI: ${colors.cyan}${mailpitResult.meta.apiBaseUrl}${colors.reset}`);
}
console.log('');
log.info('Containers are running in the background');
log.info(`Run ${colors.bright}pnpm dev${colors.reset} in another terminal to start n8n`);
log.info(
`Cleanup: ${colors.bright}pnpm --filter n8n-containers services:clean${colors.reset}`,
);
console.log('');
} catch (error) {
log.error(
`Failed to start services: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
return;
}
log.header('Starting n8n Stack');
log.info(`Project name: ${config.projectName}`);
displayConfig(config);
+3
View File
@@ -18,6 +18,8 @@
"stack:clean:containers": "docker ps -aq --filter 'name=n8n-stack-*' | xargs -r docker rm -f 2>/dev/null",
"stack:clean:networks": "docker network ls --filter 'label=org.testcontainers=true' -q | xargs -r docker network rm 2>/dev/null",
"stack:clean:all": "pnpm run stack:clean:containers && pnpm run stack:clean:networks",
"services": "tsx ./n8n-start-stack.ts --services-only",
"services:clean": "docker ps -aq --filter 'name=n8n-svc-*' | xargs docker rm -f 2>/dev/null; rm -f ../../../packages/cli/bin/.env",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix"
},
@@ -31,6 +33,7 @@
"@testcontainers/postgresql": "^11.0.3",
"@testcontainers/redis": "^11.0.3",
"get-port": "^7.1.0",
"mockserver-client": "^5.15.0",
"kafkajs": "catalog:",
"testcontainers": "^11.11.0"
}
@@ -76,9 +76,9 @@ export const gitea: Service<GiteaResult> = {
}
},
env(): Record<string, string> {
env(result: GiteaResult, external?: boolean): Record<string, string> {
return {
N8N_SOURCECONTROL_HOST: `http://${HOSTNAME}:${HTTP_PORT}`,
N8N_SOURCECONTROL_HOST: external ? result.meta.apiUrl : `http://${HOSTNAME}:${HTTP_PORT}`,
};
},
};
@@ -41,8 +41,11 @@ export const kafka: Service<KafkaResult> = {
};
},
env(): Record<string, string> {
return {};
env(result: KafkaResult, external?: boolean): Record<string, string> {
if (!external) return {};
return {
KAFKA_BROKER: result.meta.externalBroker,
};
},
};
@@ -306,7 +306,14 @@ export const keycloak: Service<KeycloakResult> = {
}
},
env(): Record<string, string> {
env(result: KeycloakResult, external?: boolean): Record<string, string> {
if (external) {
return {
N8N_OIDC_DISCOVERY_URL: result.meta.discoveryUrl,
N8N_OIDC_CLIENT_ID: result.meta.clientId,
N8N_OIDC_CLIENT_SECRET: result.meta.clientSecret,
};
}
return {
NODE_EXTRA_CA_CERTS: N8N_KEYCLOAK_CERT_PATH,
NO_PROXY: `localhost,127.0.0.1,${HOSTNAME},host.docker.internal`,
@@ -84,11 +84,9 @@ export const localstack: Service<LocalStackResult> = {
}
},
env(result: LocalStackResult): Record<string, string> {
env(result: LocalStackResult, external?: boolean): Record<string, string> {
return {
// AWS SDK v3 standard endpoint override
AWS_ENDPOINT_URL: result.meta.internalEndpoint,
// Dummy credentials (LocalStack doesn't validate by default)
AWS_ENDPOINT_URL: external ? result.meta.endpoint : result.meta.internalEndpoint,
AWS_ACCESS_KEY_ID: 'test',
AWS_SECRET_ACCESS_KEY: 'test',
AWS_DEFAULT_REGION: DEFAULT_REGION,
@@ -114,11 +114,13 @@ export const mailpit: Service<MailpitResult> = {
}
},
env(): Record<string, string> {
env(result: MailpitResult, external?: boolean): Record<string, string> {
return {
N8N_EMAIL_MODE: 'smtp',
N8N_SMTP_HOST: HOSTNAME,
N8N_SMTP_PORT: String(SMTP_PORT),
N8N_SMTP_HOST: external ? result.container.getHost() : HOSTNAME,
N8N_SMTP_PORT: external
? String(result.container.getMappedPort(SMTP_PORT))
: String(SMTP_PORT),
N8N_SMTP_SSL: 'false',
N8N_SMTP_SENDER: 'test@n8n.local',
};
@@ -128,8 +130,16 @@ export const mailpit: Service<MailpitResult> = {
export class MailpitHelper {
private readonly apiBaseUrl: string;
constructor(apiBaseUrl: string) {
/** SMTP host that n8n should use to send email (internal hostname in container mode, localhost in local mode) */
readonly smtpHost: string;
/** SMTP port that n8n should use to send email (1025 in container mode, mapped port in local mode) */
readonly smtpPort: number;
constructor(apiBaseUrl: string, smtpHost = HOSTNAME, smtpPort = SMTP_PORT) {
this.apiBaseUrl = apiBaseUrl;
this.smtpHost = smtpHost;
this.smtpPort = smtpPort;
}
async clear(): Promise<void> {
+10 -2
View File
@@ -54,7 +54,15 @@ export const mysqlService: Service<MySqlResult> = {
};
},
env(): Record<string, string> {
return {};
env(result: MySqlResult, external?: boolean): Record<string, string> {
if (!external) return {};
return {
DB_TYPE: 'mysqldb',
DB_MYSQLDB_HOST: result.meta.externalHost,
DB_MYSQLDB_PORT: String(result.meta.externalPort),
DB_MYSQLDB_DATABASE: result.meta.database,
DB_MYSQLDB_USER: result.meta.username,
DB_MYSQLDB_PASSWORD: result.meta.password,
};
},
};
@@ -45,11 +45,11 @@ export const postgres: Service<PostgresResult> = {
};
},
env(result: PostgresResult): Record<string, string> {
env(result: PostgresResult, external?: boolean): Record<string, string> {
return {
DB_TYPE: 'postgresdb',
DB_POSTGRESDB_HOST: HOSTNAME,
DB_POSTGRESDB_PORT: '5432',
DB_POSTGRESDB_HOST: external ? result.container.getHost() : HOSTNAME,
DB_POSTGRESDB_PORT: external ? String(result.container.getMappedPort(5432)) : '5432',
DB_POSTGRESDB_DATABASE: result.meta.database,
DB_POSTGRESDB_USER: result.meta.username,
DB_POSTGRESDB_PASSWORD: result.meta.password,
+313 -7
View File
@@ -1,8 +1,19 @@
import crypto from 'crypto';
import { promises as fs } from 'fs';
import type { Expectation, RequestDefinition } from 'mockserver-client';
import { mockServerClient } from 'mockserver-client';
import type { HttpRequest, HttpResponse } from 'mockserver-client/mockServer';
import type {
MockServerClient,
PathOrRequestDefinition,
RequestResponse,
} from 'mockserver-client/mockServerClient';
import { join } from 'path';
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult } from './types';
import type { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'proxyserver';
const PORT = 1080;
@@ -18,10 +29,13 @@ export type ProxyResult = ServiceResult<ProxyMeta>;
export const proxy: Service<ProxyResult> = {
description: 'HTTP proxy server',
extraEnv(result): Record<string, string> {
extraEnv(result: ProxyResult, external?: boolean): Record<string, string> {
const url = external
? `http://${result.container.getHost()}:${result.container.getMappedPort(PORT)}`
: result.meta.internalUrl;
return {
HTTP_PROXY: result.meta.internalUrl,
HTTPS_PROXY: result.meta.internalUrl,
HTTP_PROXY: url,
HTTPS_PROXY: url,
NODE_TLS_REJECT_UNAUTHORIZED: '0',
};
},
@@ -57,10 +71,302 @@ export const proxy: Service<ProxyResult> = {
}
},
env(result): Record<string, string> {
env(result: ProxyResult, external?: boolean): Record<string, string> {
return {
N8N_PROXY_HOST: result.meta.host,
N8N_PROXY_PORT: String(result.meta.port),
N8N_PROXY_HOST: external ? result.container.getHost() : result.meta.host,
N8N_PROXY_PORT: external
? String(result.container.getMappedPort(PORT))
: String(result.meta.port),
};
},
};
// --- ProxyServer helper (MockServer API client) ---
export type RequestMade = {
httpRequest?: HttpRequest;
httpResponse?: HttpResponse;
timestamp?: string;
};
export interface ProxyServerRequest {
method: string;
path: string;
queryStringParameters?: Record<string, string[]>;
headers?: Record<string, string[]>;
body?: string | { type?: string; [key: string]: unknown };
}
export interface ProxyServerResponse {
statusCode: number;
headers?: Record<string, string[]>;
body?: string;
delay?: {
timeUnit: 'MICROSECONDS' | 'MILLISECONDS' | 'SECONDS' | 'MINUTES';
value: number;
};
}
export interface ProxyServerExpectation {
httpRequest: ProxyServerRequest;
httpResponse: ProxyServerResponse;
times?: {
remainingTimes?: number;
unlimited?: boolean;
};
}
export interface RequestLog {
method: string;
path: string;
headers: Record<string, string[]>;
queryStringParameters?: Record<string, string[]>;
body?: string;
timestamp: string;
}
export class ProxyServer {
private client: MockServerClient;
url: string;
private expectationsDir: string;
constructor(proxyServerUrl: string, expectationsDir = './expectations') {
this.url = proxyServerUrl;
this.expectationsDir = expectationsDir;
const parsedURL = new URL(proxyServerUrl);
this.client = mockServerClient(parsedURL.hostname, parseInt(parsedURL.port, 10));
}
async loadExpectations(
folderName: string,
options: { strictBodyMatching?: boolean } = {},
): Promise<void> {
try {
const targetDir = join(this.expectationsDir, folderName);
const files = await fs.readdir(targetDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
const expectations: Expectation[] = [];
for (const file of jsonFiles) {
try {
const filePath = join(targetDir, file);
const fileContent = await fs.readFile(filePath, 'utf8');
const expectation = JSON.parse(fileContent) as Expectation;
if (
options.strictBodyMatching &&
expectation.httpRequest &&
'body' in expectation.httpRequest
) {
(expectation.httpRequest as { body: { matchType: string } }).body.matchType = 'STRICT';
}
expectations.push(expectation);
} catch (parseError) {
console.log(`Error parsing expectation from ${file}:`, parseError);
}
}
if (expectations.length > 0) {
console.log('Loading expectations:', expectations.length);
await this.client.mockAnyResponse(expectations);
}
} catch (error) {
console.log('Error loading expectations:', error);
}
}
async createExpectation(expectation: ProxyServerExpectation): Promise<RequestResponse> {
try {
return await this.client.mockAnyResponse({
httpRequest: expectation.httpRequest,
httpResponse: expectation.httpResponse,
times: expectation.times,
});
} catch (error) {
throw new Error(
`Failed to create expectation: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async verifyRequest(request: RequestDefinition, numberOfRequests: number): Promise<boolean> {
try {
await this.client.verify(request, numberOfRequests, numberOfRequests);
return true;
} catch (error) {
console.log('error', error);
return false;
}
}
async clearAllExpectations(): Promise<void> {
try {
await this.client.clear('', 'ALL');
} catch (error) {
throw new Error(`Failed to clear ProxyServer: ${JSON.stringify(error)}`);
}
}
async createGetExpectation(
path: string,
responseBody: unknown,
queryParams?: Record<string, string>,
statusCode: number = 200,
): Promise<RequestResponse> {
const queryStringParameters = queryParams
? Object.entries(queryParams).reduce<Record<string, string[]>>((acc, [key, value]) => {
acc[key] = [value];
return acc;
}, {})
: undefined;
return await this.createExpectation({
httpRequest: {
method: 'GET',
path,
...(queryStringParameters && { queryStringParameters }),
},
httpResponse: {
statusCode,
headers: {
'Content-Type': ['application/json'],
},
body: JSON.stringify(responseBody),
},
});
}
async wasRequestMade(request: RequestDefinition, numberOfRequests = 1): Promise<boolean> {
return await this.verifyRequest(request, numberOfRequests);
}
async getAllRequestsMade(): Promise<RequestMade[]> {
// @ts-expect-error mockserver types seem to be messed up
return await this.client.retrieveRecordedRequestsAndResponses('');
}
async recordExpectations(
folderName: string,
options?: {
pathOrRequestDefinition?: PathOrRequestDefinition;
host?: string;
dedupe?: boolean;
raw?: boolean;
transform?: (expectation: Expectation) => Expectation;
},
): Promise<void> {
try {
const recordedExpectations = await this.client.retrieveRecordedExpectations(
options?.pathOrRequestDefinition,
);
const targetDir = join(this.expectationsDir, folderName);
await fs.mkdir(targetDir, { recursive: true });
const seenRequests = new Set<string>();
for (const expectation of recordedExpectations) {
if (
!expectation.httpRequest ||
!(
'method' in expectation.httpRequest &&
typeof expectation.httpRequest.method === 'string' &&
typeof expectation.httpRequest.path === 'string'
)
) {
continue;
}
const headers = (expectation.httpRequest.headers ?? {}) as Record<string, unknown>;
const hostHeader = 'Host' in headers ? (headers.Host as string | string[]) : undefined;
const hostName = Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? 'unknown-host');
if (options?.host && typeof hostName === 'string' && !hostName.includes(options.host)) {
continue;
}
const method = expectation.httpRequest.method;
let requestForProcessing: Record<string, unknown> | HttpRequest;
if (options?.raw) {
requestForProcessing = expectation.httpRequest;
} else {
const cleanedRequest: Record<string, unknown> = {
method: expectation.httpRequest.method,
path: expectation.httpRequest.path,
};
if (method === 'GET') {
if (expectation.httpRequest.queryStringParameters) {
cleanedRequest.queryStringParameters = expectation.httpRequest.queryStringParameters;
}
} else if (method === 'POST' || method === 'PUT') {
if (expectation.httpRequest.body) {
cleanedRequest.body = expectation.httpRequest.body;
}
}
requestForProcessing = cleanedRequest;
}
if (options?.dedupe) {
const dedupeKey = JSON.stringify(requestForProcessing);
if (seenRequests.has(dedupeKey)) {
continue;
}
seenRequests.add(dedupeKey);
}
let processedExpectation: Expectation = {
...expectation,
httpRequest: requestForProcessing,
times: {
unlimited: true,
},
};
if (options?.transform) {
processedExpectation = options.transform(processedExpectation);
}
const hash = crypto
.createHash('sha256')
.update(JSON.stringify(requestForProcessing))
.digest('hex')
.substring(0, 8);
const filename = `${Date.now()}-${hostName}-${method}-${expectation.httpRequest.path.replace(/[^a-zA-Z0-9]/g, '_')}-${hash}.json`;
processedExpectation.id = filename;
const filePath = join(targetDir, filename);
await fs.writeFile(filePath, JSON.stringify(processedExpectation, null, 2));
}
} catch (error) {
throw new Error(`Failed to record expectations: ${JSON.stringify(error)}`);
}
}
async getActiveExpectations() {
return await this.client.retrieveActiveExpectations({ method: 'GET' });
}
}
export function createProxyHelper(ctx: HelperContext): ProxyServer {
const result = ctx.serviceResults.proxy as ProxyResult | undefined;
if (!result) {
throw new Error('Proxy service not found in context');
}
const url = `http://${result.container.getHost()}:${result.container.getMappedPort(PORT)}`;
return new ProxyServer(url);
}
declare module './types' {
interface ServiceHelpers {
proxy: ProxyServer;
}
}
+10 -5
View File
@@ -38,14 +38,19 @@ export const redis: Service<RedisResult> = {
};
},
env(): Record<string, string> {
env(result: RedisResult, external?: boolean): Record<string, string> {
const host = external ? result.container.getHost() : HOSTNAME;
const port = external ? String(result.container.getMappedPort(6379)) : '6379';
return {
QUEUE_BULL_REDIS_HOST: HOSTNAME,
QUEUE_BULL_REDIS_PORT: '6379',
// In container mode, EXECUTIONS_MODE is set by the stack based on worker count.
// In external/local mode, redis implies the user wants queue mode.
...(external ? { EXECUTIONS_MODE: 'queue' } : {}),
QUEUE_BULL_REDIS_HOST: host,
QUEUE_BULL_REDIS_PORT: port,
N8N_CACHE_ENABLED: 'true',
N8N_CACHE_BACKEND: 'redis',
N8N_CACHE_REDIS_HOST: HOSTNAME,
N8N_CACHE_REDIS_PORT: '6379',
N8N_CACHE_REDIS_HOST: host,
N8N_CACHE_REDIS_PORT: port,
};
},
};
@@ -9,7 +9,7 @@ import { mysqlService } from './mysql';
import { ngrok } from './ngrok';
import { createObservabilityHelper } from './observability';
import { postgres } from './postgres';
import { proxy } from './proxy';
import { proxy, createProxyHelper } from './proxy';
import { redis } from './redis';
import { taskRunner } from './task-runner';
import { tracing, createTracingHelper } from './tracing';
@@ -45,6 +45,7 @@ export const helperFactories: Partial<HelperFactories> = {
keycloak: createKeycloakHelper,
observability: createObservabilityHelper,
tracing: createTracingHelper,
proxy: createProxyHelper,
kafka: createKafkaHelper,
localstack: createLocalStackHelper,
};
@@ -81,10 +81,10 @@ export interface Service<TResult extends ServiceResult = ServiceResult> {
options?: unknown,
ctx?: StartContext,
): Promise<TResult>;
/** @example () => ({ QUEUE_BULL_REDIS_HOST: 'redis' }) */
env?(result: TResult): Record<string, string>;
/** @example () => ({ N8N_EXTERNAL_STORAGE_ENABLED: 'true' }) */
extraEnv?(result: TResult): Record<string, string>;
/** @param external When true, returns host-compatible values using mapped ports (for local dev) */
env?(result: TResult, external?: boolean): Record<string, string>;
/** @param external When true, returns host-compatible values using mapped ports (for local dev) */
extraEnv?(result: TResult, external?: boolean): Record<string, string>;
/** Verifies service is reachable from inside n8n containers */
verifyFromN8n?(result: TResult, n8nContainers: StartedTestContainer[]): Promise<void>;
}
+3 -1
View File
@@ -40,10 +40,12 @@ export interface N8NStack {
}
function shouldServiceStart(name: ServiceName, service: Service, ctx: StartContext): boolean {
// Explicitly requested services always start
if (ctx.config.services?.includes(name)) return true;
if (service.shouldStart) {
return service.shouldStart(ctx);
}
return ctx.config.services?.includes(name) ?? false;
return false;
}
function groupByDependencyLevel(serviceNames: ServiceName[]): ServiceName[][] {
+8 -26
View File
@@ -1,6 +1,7 @@
import type { CurrentsFixtures, CurrentsWorkerFixtures } from '@currents/playwright';
import { fixtures as currentsFixtures } from '@currents/playwright';
import { test as base, expect, request } from '@playwright/test';
import type { ServiceHelpers } from 'n8n-containers/services/types';
import type { N8NConfig, N8NStack } from 'n8n-containers/stack';
import { createN8NStack } from 'n8n-containers/stack';
@@ -11,7 +12,6 @@ import { setupDefaultInterceptors } from '../config/intercepts';
import { observabilityFixtures, type ObservabilityTestFixtures } from '../fixtures/observability';
import { n8nPage } from '../pages/n8nPage';
import { ApiHelpers } from '../services/api-helper';
import { ProxyServer } from '../services/proxy-server';
import { TestError, type TestRequirements } from '../Types';
import { setupTestRequirements } from '../utils/requirements';
import { getBackendUrl, getFrontendUrl } from '../utils/url-helper';
@@ -21,7 +21,8 @@ type TestFixtures = {
api: ApiHelpers;
baseURL: string;
setupRequirements: (requirements: TestRequirements) => Promise<void>;
proxyServer: ProxyServer;
/** Type-safe service helpers (mailpit, gitea, proxy, observability, etc.) */
services: ServiceHelpers;
/**
* Direct URLs to each main instance (bypasses load balancer).
* Only available in container mode with multi-main setup.
@@ -280,25 +281,8 @@ export const test = base.extend<
await use(setupFunction);
},
proxyServer: async ({ n8nContainer }, use) => {
if (!n8nContainer) {
throw new TestError(
'Testing with Proxy server is not supported when using N8N_BASE_URL environment variable. Remove N8N_BASE_URL to use containerized testing.',
);
}
const proxyServerContainer = n8nContainer.containers.find((container) =>
container.getName().endsWith('proxyserver'),
);
if (!proxyServerContainer) {
throw new TestError('Proxy server container not initialized. Cannot initialize client.');
}
const serverUrl = `http://${proxyServerContainer?.getHost()}:${proxyServerContainer?.getFirstMappedPort()}`;
const proxyServer = new ProxyServer(serverUrl);
await use(proxyServer);
services: async ({ n8nContainer }, use) => {
await use(n8nContainer.services);
},
});
@@ -309,10 +293,8 @@ Fixture Dependency Graph:
Worker: capability + project.containerConfig → n8nContainer → [backendUrl, frontendUrl, dbSetup]
Test: frontendUrl + dbSetup → baseURL → n8n (uses backendUrl for API calls)
backendUrl → api
n8nContainer → services
n8nContainer provides unified access to:
- services: Type-safe helpers (mailpit, gitea, observability, etc.)
- logs/metrics: Shortcuts for observability queries
- findContainers/stopContainer: Container operations for chaos testing
- serviceResults: Raw service results (advanced use)
services: Type-safe helpers (mailpit, gitea, proxy, observability, etc.)
n8nContainer: Container lifecycle (stop, containers, mainUrls, etc.)
*/
@@ -15,7 +15,12 @@ import { getBackendUrl, getFrontendUrl } from './utils/url-helper';
// - @licensed - enterprise license features (log streaming, SSO, etc.)
// - @db:reset - tests needing per-test database reset (requires isolated containers)
const CONTAINER_ONLY = new RegExp(
`@capability:(${CONTAINER_ONLY_CAPABILITIES.join('|')})|@mode:(${CONTAINER_ONLY_MODES.join('|')})|@${LICENSED_TAG}|@db:reset`,
[
`@capability:(${CONTAINER_ONLY_CAPABILITIES.join('|')})`,
`@mode:(${CONTAINER_ONLY_MODES.join('|')})`,
`@${LICENSED_TAG}`,
'@db:reset',
].join('|'),
);
const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
@@ -1,329 +0,0 @@
/**
* ProxyServer service helper functions for Playwright tests
*/
import crypto from 'crypto';
import { promises as fs } from 'fs';
import type { Expectation, RequestDefinition } from 'mockserver-client';
import { mockServerClient as proxyServerClient } from 'mockserver-client';
import type { HttpRequest, HttpResponse } from 'mockserver-client/mockServer';
import type {
MockServerClient,
PathOrRequestDefinition,
RequestResponse,
} from 'mockserver-client/mockServerClient';
import { join } from 'path';
export type RequestMade = {
httpRequest?: HttpRequest;
httpResponse?: HttpResponse;
timestamp?: string;
};
export interface ProxyServerRequest {
method: string;
path: string;
queryStringParameters?: Record<string, string[]>;
headers?: Record<string, string[]>;
body?: string | { type?: string; [key: string]: unknown };
}
export interface ProxyServerResponse {
statusCode: number;
headers?: Record<string, string[]>;
body?: string;
delay?: {
timeUnit: 'MICROSECONDS' | 'MILLISECONDS' | 'SECONDS' | 'MINUTES';
value: number;
};
}
export interface ProxyServerExpectation {
httpRequest: ProxyServerRequest;
httpResponse: ProxyServerResponse;
times?: {
remainingTimes?: number;
unlimited?: boolean;
};
}
export interface RequestLog {
method: string;
path: string;
headers: Record<string, string[]>;
queryStringParameters?: Record<string, string[]>;
body?: string;
timestamp: string;
}
export class ProxyServer {
private client: MockServerClient;
url: string;
private expectationsDir = './expectations';
/**
* Create a ProxyServer client instance from a URL
*/
constructor(proxyServerUrl: string) {
this.url = proxyServerUrl;
const parsedURL = new URL(proxyServerUrl);
this.client = proxyServerClient(parsedURL.hostname, parseInt(parsedURL.port, 10));
}
/**
* Load all expectations from the specified subfolder and mock them
*/
async loadExpectations(
folderName: string,
options: { strictBodyMatching?: boolean } = {},
): Promise<void> {
try {
const targetDir = join(this.expectationsDir, folderName);
const files = await fs.readdir(targetDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
const expectations: Expectation[] = [];
for (const file of jsonFiles) {
try {
const filePath = join(targetDir, file);
const fileContent = await fs.readFile(filePath, 'utf8');
const expectation = JSON.parse(fileContent);
if (options.strictBodyMatching && expectation.httpRequest?.body) {
expectation.httpRequest.body.matchType = 'STRICT';
}
expectations.push(expectation);
} catch (parseError) {
console.log(`Error parsing expectation from ${file}:`, parseError);
}
}
if (expectations.length > 0) {
console.log('Loading expectations:', expectations.length);
await this.client.mockAnyResponse(expectations);
}
} catch (error) {
console.log('Error loading expectations:', error);
}
}
/**
* Create an expectation in ProxyServer
*/
async createExpectation(expectation: ProxyServerExpectation): Promise<RequestResponse> {
try {
return await this.client.mockAnyResponse({
httpRequest: expectation.httpRequest,
httpResponse: expectation.httpResponse,
times: expectation.times,
});
} catch (error) {
throw new Error(
`Failed to create expectation: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Verify that a request was received by ProxyServer
*/
async verifyRequest(request: RequestDefinition, numberOfRequests: number): Promise<boolean> {
try {
await this.client.verify(request, numberOfRequests, numberOfRequests);
return true;
} catch (error) {
console.log('error', error);
return false;
}
}
/**
* Clear all expectations and logs from ProxyServer
*/
async clearAllExpectations(): Promise<void> {
try {
await this.client.clear('', 'ALL');
} catch (error) {
throw new Error(`Failed to clear ProxyServer: ${JSON.stringify(error)}`);
}
}
/**
* Create a request expectation with JSON response
*/
async createGetExpectation(
path: string,
responseBody: unknown,
queryParams?: Record<string, string>,
statusCode: number = 200,
): Promise<RequestResponse> {
const queryStringParameters = queryParams
? Object.entries(queryParams).reduce<Record<string, string[]>>((acc, [key, value]) => {
acc[key] = [value];
return acc;
}, {})
: undefined;
return await this.createExpectation({
httpRequest: {
method: 'GET',
path,
...(queryStringParameters && { queryStringParameters }),
},
httpResponse: {
statusCode,
headers: {
'Content-Type': ['application/json'],
},
body: JSON.stringify(responseBody),
},
});
}
/**
* Verify a request was made to ProxyServer
*/
async wasRequestMade(request: RequestDefinition, numberOfRequests = 1): Promise<boolean> {
return await this.verifyRequest(request, numberOfRequests);
}
async getAllRequestsMade(): Promise<RequestMade[]> {
// @ts-expect-error mockserver types seem to be messed up
return await this.client.retrieveRecordedRequestsAndResponses('');
}
/**
* Retrieve recorded expectations and write to files
*
* @param folderName - Target folder name for saving expectation files
* @param options - Optional configuration
* @param options.pathOrRequestDefinition - Filter expectations by path or request definition
* @param options.host - Filter expectations by host name (partial match)
* @param options.dedupe - Remove duplicate expectations based on request
* @param options.raw - Save full original requests (true) or cleaned requests (false, default)
* - raw: false (default) - Saves only essential fields: method, path, queryStringParameters (GET), body (POST/PUT)
* - raw: true - Saves complete original request including all headers and metadata
* @param options.transform - Transform function to modify expectation before saving
*/
async recordExpectations(
folderName: string,
options?: {
pathOrRequestDefinition?: PathOrRequestDefinition;
host?: string;
dedupe?: boolean;
raw?: boolean;
transform?: (expectation: Expectation) => Expectation;
},
): Promise<void> {
try {
// Retrieve recorded expectations from the mock server
const recordedExpectations = await this.client.retrieveRecordedExpectations(
options?.pathOrRequestDefinition,
);
// Create target directory path
const targetDir = join(this.expectationsDir, folderName);
// Ensure target directory exists
await fs.mkdir(targetDir, { recursive: true });
const seenRequests = new Set<string>();
for (const expectation of recordedExpectations) {
if (
!expectation.httpRequest ||
!(
'method' in expectation.httpRequest &&
typeof expectation.httpRequest.method === 'string' &&
typeof expectation.httpRequest.path === 'string'
)
) {
continue;
}
// Extract host for filename and filtering
const headers = expectation.httpRequest.headers ?? {};
const hostHeader = 'Host' in headers ? headers?.Host : undefined;
const hostName = Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? 'unknown-host');
if (options?.host && typeof hostName === 'string' && !hostName.includes(options.host)) {
continue;
}
const method = expectation.httpRequest.method;
let requestForProcessing: Record<string, unknown> | HttpRequest;
if (options?.raw) {
// Use raw request without cleaning
requestForProcessing = expectation.httpRequest;
} else {
// Clean up the request data
const cleanedRequest: Record<string, unknown> = {
method: expectation.httpRequest.method,
path: expectation.httpRequest.path,
};
// Include different fields based on method
if (method === 'GET') {
// For GET requests, include queryStringParameters if present
if (expectation.httpRequest.queryStringParameters) {
cleanedRequest.queryStringParameters = expectation.httpRequest.queryStringParameters;
}
} else if (method === 'POST' || method === 'PUT') {
// For POST/PUT requests, include body if present
if (expectation.httpRequest.body) {
cleanedRequest.body = expectation.httpRequest.body;
}
}
requestForProcessing = cleanedRequest;
}
// Dedupe expectations if requested
if (options?.dedupe) {
const dedupeKey = JSON.stringify(requestForProcessing);
if (seenRequests.has(dedupeKey)) {
continue;
}
seenRequests.add(dedupeKey);
}
// Create expectation (cleaned or raw)
let processedExpectation: Expectation = {
...expectation,
httpRequest: requestForProcessing,
times: {
unlimited: true,
},
};
// Apply transform if provided
if (options?.transform) {
processedExpectation = options.transform(processedExpectation);
}
// Generate unique filename based on request details
const hash = crypto
.createHash('sha256')
.update(JSON.stringify(requestForProcessing))
.digest('hex')
.substring(0, 8);
const filename = `${Date.now()}-${hostName}-${method}-${expectation.httpRequest.path.replace(/[^a-zA-Z0-9]/g, '_')}-${hash}.json`;
processedExpectation.id = filename;
const filePath = join(targetDir, filename);
// Write expectation to JSON file
await fs.writeFile(filePath, JSON.stringify(processedExpectation, null, 2));
}
} catch (error) {
throw new Error(`Failed to record expectations: ${JSON.stringify(error)}`);
}
}
async getActiveExpectations() {
return await this.client.retrieveActiveExpectations({ method: 'GET' });
}
}
@@ -3,8 +3,8 @@ import { expect, test } from '../../../fixtures/base';
test.use({ capability: 'proxy' });
test.describe('Evaluations @capability:proxy', () => {
test.beforeEach(async ({ n8n, proxyServer }) => {
await proxyServer.clearAllExpectations();
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await n8n.goHome();
});
@@ -12,8 +12,8 @@ test.describe('Evaluations @capability:proxy', () => {
// @AI team to look at this
test.fixme(
'should load evaluations workflow and execute twice @fixme',
async ({ n8n, proxyServer }) => {
await proxyServer.loadExpectations('evaluations');
async ({ n8n, services }) => {
await services.proxy.loadExpectations('evaluations');
await n8n.api.credentials.createCredentialFromDefinition({
name: 'Test Google Sheets',
@@ -74,9 +74,9 @@ m82JpEptTfAxFHtd8+Sb0U2G
await n8n.notifications.waitForNotificationAndClose('Successful', { timeout: 10000 });
// 💡 To update recordings, remove stored expectations, set real credentials above and rerecord here.
// await proxyServer.recordExpectations('evaluations', { host: 'google', dedupe: true });
// await services.proxy.recordExpectations('evaluations', { host: 'google', dedupe: true });
const batchUpdateRequests = (await proxyServer.getAllRequestsMade()).filter((request) => {
const batchUpdateRequests = (await services.proxy.getAllRequestsMade()).filter((request) => {
const path = request.httpRequest?.path;
const method = request.httpRequest?.method;
@@ -52,9 +52,9 @@ const hitlForToolsTestConfig = {
test.use(hitlForToolsTestConfig);
test.describe('HITL for Tools @capability:proxy', () => {
test.beforeEach(async ({ n8n, proxyServer }) => {
await proxyServer.clearAllExpectations();
await proxyServer.loadExpectations('hitl-for-tools');
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('hitl-for-tools');
await n8n.canvas.openNewWorkflow();
});
@@ -77,9 +77,9 @@ async function setupBasicAgentWorkflow(n8n: n8nPage, additionalNodes: string[] =
test.use({ capability: 'proxy' });
test.describe('Langchain Integration @capability:proxy', () => {
test.beforeEach(async ({ n8n, proxyServer }) => {
await proxyServer.clearAllExpectations();
await proxyServer.loadExpectations('langchain');
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('langchain');
await n8n.canvas.openNewWorkflow();
});
@@ -40,9 +40,9 @@ async function executeChatAndWaitForResponse(n8n: n8nPage, message: string) {
test.use({ capability: 'proxy' });
test.describe('Langchain Integration @capability:proxy', () => {
test.beforeEach(async ({ n8n, proxyServer }) => {
await proxyServer.clearAllExpectations();
await proxyServer.loadExpectations('langchain');
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('langchain');
await n8n.canvas.openNewWorkflow();
});
@@ -50,9 +50,9 @@ async function verifyChatMessages(n8n: n8nPage, expectedCount: number, inputMess
test.use({ capability: 'proxy' });
test.describe('Langchain Integration @capability:proxy', () => {
test.beforeEach(async ({ n8n, proxyServer }) => {
await proxyServer.clearAllExpectations();
await proxyServer.loadExpectations('langchain');
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('langchain');
await n8n.canvas.openNewWorkflow();
});
@@ -53,9 +53,9 @@ async function setupBasicAgentWorkflow(n8n: n8nPage, additionalNodes: string[] =
test.use({ capability: 'proxy' });
test.describe('Langchain Integration @capability:proxy', () => {
test.beforeEach(async ({ n8n, proxyServer }) => {
await proxyServer.clearAllExpectations();
await proxyServer.loadExpectations('langchain');
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('langchain');
await n8n.canvas.openNewWorkflow();
});
@@ -10,9 +10,9 @@ async function waitForWorkflowSuccess(n8n: n8nPage, timeout = 10000) {
test.use({ capability: 'proxy' });
test.describe('Langchain Integration @capability:proxy', () => {
test.beforeEach(async ({ n8n, proxyServer }) => {
await proxyServer.clearAllExpectations();
await proxyServer.loadExpectations('langchain');
test.beforeEach(async ({ n8n, services }) => {
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('langchain');
await n8n.canvas.openNewWorkflow();
});
@@ -31,10 +31,10 @@ test.use({
});
test.describe('Workflow Builder @auth:owner @ai @capability:proxy', () => {
test.beforeEach(async ({ setupRequirements, proxyServer }) => {
test.beforeEach(async ({ setupRequirements, services }) => {
await setupRequirements(workflowBuilderEnabledRequirements);
await proxyServer.clearAllExpectations();
await proxyServer.loadExpectations('workflow-builder');
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('workflow-builder');
});
test('should show Build with AI button on empty canvas', async ({ n8n }) => {
@@ -9,9 +9,9 @@ test.describe('OIDC Authentication @capability:oidc', () => {
test('should configure OIDC and login with Keycloak @auth:owner', async ({
n8n,
api,
n8nContainer,
services,
}) => {
const keycloak = n8nContainer.services.keycloak;
const keycloak = services.keycloak;
await api.enableFeature('oidc');
await n8n.oidcComposer.configureOidc(
keycloak.internalDiscoveryUrl,
@@ -2,14 +2,14 @@ import { test, expect } from '../../../fixtures/base';
test.use({ capability: 'email' });
test('Password reset email is delivered @capability:email', async ({ api, n8nContainer }) => {
test('Password reset email is delivered @capability:email', async ({ api, services }) => {
const ownerEmail = 'nathan@n8n.io';
const res = await api.request.post('/rest/forgot-password', {
data: { email: ownerEmail },
});
expect(res.ok()).toBeTruthy();
const msg = await n8nContainer.services.mailpit.waitForMessage({
const msg = await services.mailpit.waitForMessage({
to: ownerEmail,
subject: /password reset/i,
});
@@ -5,34 +5,34 @@ import { test, expect } from '../../../fixtures/base';
test.use({ capability: 'proxy' });
// @capability:proxy tag ensures that test suite is only run when proxy is available
test.describe('Proxy server @capability:proxy', () => {
test.beforeEach(async ({ proxyServer }) => {
await proxyServer.clearAllExpectations();
test.beforeEach(async ({ services }) => {
await services.proxy.clearAllExpectations();
});
test('should verify ProxyServer container is running', async ({ proxyServer }) => {
const mockResponse = await proxyServer.createGetExpectation('/health', {
test('should verify ProxyServer container is running', async ({ services }) => {
const mockResponse = await services.proxy.createGetExpectation('/health', {
status: 'healthy',
});
assert(typeof mockResponse !== 'string');
expect(mockResponse.statusCode).toBe(201);
expect(await proxyServer.wasRequestMade({ method: 'GET', path: '/health' })).toBe(false);
expect(await services.proxy.wasRequestMade({ method: 'GET', path: '/health' })).toBe(false);
// Verify the mock endpoint works
const healthResponse = await fetch(`${proxyServer.url}/health`);
const healthResponse = await fetch(`${services.proxy.url}/health`);
expect(healthResponse.ok).toBe(true);
const healthData = await healthResponse.json();
expect(healthData.status).toBe('healthy');
expect(await proxyServer.wasRequestMade({ method: 'GET', path: '/health' })).toBe(true);
expect(await services.proxy.wasRequestMade({ method: 'GET', path: '/health' })).toBe(true);
});
test('should run a simple workflow calling http endpoint', async ({ n8n, proxyServer }) => {
test('should run a simple workflow calling http endpoint', async ({ n8n, services }) => {
const mockResponse = { data: 'Hello from ProxyServer!', test: '1' };
// Create expectation in mockserver to handle the request
await proxyServer.createGetExpectation('/data', mockResponse, { test: '1' });
await services.proxy.createGetExpectation('/data', mockResponse, { test: '1' });
await n8n.canvas.openNewWorkflow();
@@ -46,7 +46,7 @@ test.describe('Proxy server @capability:proxy', () => {
// Verify the request was handled by mockserver
expect(
await proxyServer.wasRequestMade({
await services.proxy.wasRequestMade({
method: 'GET',
path: '/data',
queryStringParameters: { test: ['1'] },
@@ -54,14 +54,16 @@ test.describe('Proxy server @capability:proxy', () => {
).toBe(true);
});
test('should use stored expectations respond to api request', async ({ proxyServer }) => {
await proxyServer.loadExpectations('proxy-server');
test('should use stored expectations respond to api request', async ({ services }) => {
await services.proxy.loadExpectations('proxy-server');
const response = await fetch(`${proxyServer.url}/mock-endpoint`);
const response = await fetch(`${services.proxy.url}/mock-endpoint`);
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.title).toBe('delectus aut autem');
expect(await proxyServer.wasRequestMade({ method: 'GET', path: '/mock-endpoint' })).toBe(true);
expect(await services.proxy.wasRequestMade({ method: 'GET', path: '/mock-endpoint' })).toBe(
true,
);
});
test('should run a simple workflow proxying HTTPS request', async ({ n8n }) => {
@@ -37,16 +37,16 @@ export const test = base.extend<ChatHubFixtures>({
},
chatHubProxySetup: [
async ({ proxyServer }, use) => {
async ({ services }, use) => {
// Setup
await proxyServer.clearAllExpectations();
await proxyServer.loadExpectations('chat-hub', { strictBodyMatching: true });
await services.proxy.clearAllExpectations();
await services.proxy.loadExpectations('chat-hub', { strictBodyMatching: true });
await use(undefined);
// Teardown
if (!process.env.CI) {
await proxyServer.recordExpectations('chat-hub', {
await services.proxy.recordExpectations('chat-hub', {
dedupe: true,
transform: (expectation) => {
const response = expectation.httpResponse as {
@@ -2,18 +2,19 @@ import { test, expect } from '../../../fixtures/base';
test.use({ capability: 'email' });
test('EmailSend node sends via SMTP @capability:email', async ({ api, n8n, n8nContainer }) => {
test('EmailSend node sends via SMTP @capability:email', async ({ api, n8n, services }) => {
// Sign in to use internal APIs for creating credentials and workflows
const mailpit = services.mailpit;
// Create SMTP credential targeting Mailpit
// Create SMTP credential targeting Mailpit (uses internal hostname in container mode, localhost in local mode)
const smtpCredential = await api.credentials.createCredential({
name: 'SMTP (Test)',
type: 'smtp',
data: {
user: '',
password: '',
host: 'mailpit',
port: 1025,
host: mailpit.smtpHost,
port: mailpit.smtpPort,
secure: false,
disableStartTls: true,
},
@@ -73,7 +74,7 @@ test('EmailSend node sends via SMTP @capability:email', async ({ api, n8n, n8nCo
'Workflow executed successfully',
);
const msg = await n8nContainer.services.mailpit.waitForMessage({ to: toEmail, subject });
const msg = await mailpit.waitForMessage({ to: toEmail, subject });
expect(msg).toBeTruthy();
});
@@ -8,9 +8,9 @@ test.describe('Kafka Nodes', () => {
test('Kafka node publishes messages to topic @capability:kafka', async ({
api,
n8n,
n8nContainer,
services,
}) => {
const kafka = n8nContainer.services.kafka;
const kafka = services.kafka;
const topic = `producer-test-${nanoid()}`;
const testPayload = { greeting: 'Hello from n8n Kafka node' };
@@ -98,8 +98,8 @@ test.describe('Kafka Nodes', () => {
expect(JSON.parse(messages[0].value)).toMatchObject(testPayload);
});
test('Kafka Trigger node processes messages @capability:kafka', async ({ api, n8nContainer }) => {
const kafka = n8nContainer.services.kafka;
test('Kafka Trigger node processes messages @capability:kafka', async ({ api, services }) => {
const kafka = services.kafka;
const topic = `trigger-test-${nanoid()}`;
const groupId = `n8n-test-group-${nanoid()}`;
@@ -25,9 +25,9 @@ test.describe('Source Control Settings @capability:source-control @fixme', () =>
let repoUrl: string;
let repoName: string;
test.beforeEach(async ({ n8n, n8nContainer }) => {
test.beforeEach(async ({ n8n, services }) => {
await n8n.api.enableFeature('sourceControl');
const gitea = n8nContainer.services.gitea;
const gitea = services.gitea;
await initSourceControl({ n8n, gitea });
// Create unique repo with branches via API (not UI)
@@ -56,8 +56,8 @@ test.describe('Source Control Settings @capability:source-control @fixme', () =>
await expect(n8n.sideBar.getSourceControlConnectedIndicator()).toBeVisible();
});
test('should switch between branches', async ({ n8n, n8nContainer }) => {
const gitea = n8nContainer.services.gitea;
test('should switch between branches', async ({ n8n, services }) => {
const gitea = services.gitea;
await gitea.createBranch(repoName, 'development');
await gitea.createBranch(repoName, 'staging');
await gitea.createBranch(repoName, 'production');
@@ -20,16 +20,13 @@ test.describe('AWS Secrets Manager with LocalStack @capability:external-secrets
return data[PROVIDER_NAME] ?? [];
}
test.beforeEach(async ({ n8n, n8nContainer }) => {
await n8nContainer.services.localstack.secretsManager.clear();
test.beforeEach(async ({ n8n, services }) => {
await services.localstack.secretsManager.clear();
await n8n.api.enableFeature('externalSecrets');
});
test('can configure, connect, and sync secrets from LocalStack', async ({
n8n,
n8nContainer,
}) => {
const { secretsManager } = n8nContainer.services.localstack;
test('can configure, connect, and sync secrets from LocalStack', async ({ n8n, services }) => {
const { secretsManager } = services.localstack;
await secretsManager.createSecret('api-key', 'secret-123');
@@ -9,7 +9,7 @@ test.describe('Secret Providers Connections with LocalStack @capability:external
const PROVIDER_KEY = 'aws-localstack-e2e';
const PROVIDER_TYPE = 'awsSecretsManager';
test.beforeEach(async ({ n8n, n8nContainer }) => {
test.beforeEach(async ({ n8n, services }) => {
// N8N_ENV_FEAT_EXTERNAL_SECRETS_FOR_PROJECTS is set at container startup
// via the external-secrets capability config
@@ -17,7 +17,7 @@ test.describe('Secret Providers Connections with LocalStack @capability:external
await n8n.api.enableFeature('externalSecrets');
// Clear any existing secrets from previous tests
await n8nContainer.services.localstack.secretsManager.clear();
await services.localstack.secretsManager.clear();
});
test.afterEach(async ({ n8n }) => {
@@ -29,16 +29,16 @@ test.describe('Secret Providers Connections with LocalStack @capability:external
}
});
test('can create a connection pointing to LocalStack', async ({ n8n, n8nContainer }) => {
test('can create a connection pointing to LocalStack', async ({ n8n, services }) => {
// Arrange: Seed secrets in LocalStack
await n8nContainer.services.localstack.secretsManager.createSecret('e2e-api-key', 'secret-123');
await n8nContainer.services.localstack.secretsManager.createSecret(
await services.localstack.secretsManager.createSecret('e2e-api-key', 'secret-123');
await services.localstack.secretsManager.createSecret(
'e2e-db-credentials',
JSON.stringify({ username: 'admin', password: 'hunter2' }),
);
// Verify secrets exist in LocalStack
const secrets = await n8nContainer.services.localstack.secretsManager.listSecrets();
const secrets = await services.localstack.secretsManager.listSecrets();
expect(secrets).toContain('e2e-api-key');
expect(secrets).toContain('e2e-db-credentials');
@@ -20,11 +20,8 @@ test.describe('Log Streaming to VictoriaLogs @capability:observability', () => {
await n8n.api.enableFeature('logStreaming');
});
test('should configure syslog destination and send test message', async ({
api,
n8nContainer,
}) => {
const obs = n8nContainer.services.observability;
test('should configure syslog destination and send test message', async ({ api, services }) => {
const obs = services.observability;
// Configure syslog destination pointing to VictoriaLogs
// syslog contains: host, port, protocol, facility, appName
@@ -57,8 +54,8 @@ test.describe('Log Streaming to VictoriaLogs @capability:observability', () => {
await api.deleteLogStreamingDestination(destination.id);
});
test('should query metrics from VictoriaMetrics', async ({ api, n8nContainer }) => {
const obs = n8nContainer.services.observability;
test('should query metrics from VictoriaMetrics', async ({ api, services }) => {
const obs = services.observability;
// Import and activate a webhook workflow to generate metrics
const { webhookPath, workflowId } = await api.workflows.importWorkflowFromFile(
@@ -18,9 +18,9 @@ test.describe('Log Streaming UI E2E @capability:observability', () => {
test('should configure syslog destination via UI and send test event', async ({
n8n,
n8nContainer,
services,
}) => {
const obs = n8nContainer.services.observability;
const obs = services.observability;
// ========== STEP 1: Configure Log Streaming via UI ==========
await n8n.navigate.toLogStreaming();
@@ -18,10 +18,10 @@ test.describe('Pull resources from Git @capability:source-control @fixme', () =>
let gitRepo: GitRepoHelper;
test.beforeEach(async ({ n8n, n8nContainer }) => {
test.beforeEach(async ({ n8n, services }) => {
await n8n.api.enableFeature('sourceControl');
await n8n.api.enableFeature('variables');
gitRepo = await setupGitRepo(n8n, n8nContainer.services.gitea);
gitRepo = await setupGitRepo(n8n, services.gitea);
});
test('should pull new resources from remote', async ({ n8n }) => {
@@ -24,11 +24,11 @@ test.describe('Push resources to Git @capability:source-control @fixme', () => {
let gitRepo: GitRepoHelper;
test.beforeEach(async ({ n8n, n8nContainer }) => {
test.beforeEach(async ({ n8n, services }) => {
await n8n.api.enableFeature('sourceControl');
await n8n.api.enableFeature('variables');
gitRepo = await setupGitRepo(n8n, n8nContainer.services.gitea);
gitRepo = await setupGitRepo(n8n, services.gitea);
});
test('should push a new workflow', async ({ n8n }) => {
@@ -11,10 +11,11 @@ const getContainerName = (log: LogEntry): string | undefined => log.container_na
test('Leader election @mode:multi-main @chaostest @capability:observability', async ({
n8nContainer,
services,
}) => {
// Find the current leader by querying VictoriaLogs
// Vector enriches logs with container_name from Docker metadata
const leaderLog = await n8nContainer.logs.waitForLog('Leader is now this', {
const leaderLog = await services.observability.logs.waitForLog('Leader is now this', {
timeoutMs: 30000,
start: '-5m',
});
@@ -30,7 +31,7 @@ test('Leader election @mode:multi-main @chaostest @capability:observability', as
// Wait for new leader election (another instance should take over)
// Use LogsQL to exclude logs from the stopped container
const newLeaderLog = await n8nContainer.logs.waitForLog(
const newLeaderLog = await services.observability.logs.waitForLog(
`Leader is now this AND NOT container_name:${currentLeader}`,
{
timeoutMs: 30000,
@@ -15,7 +15,7 @@ test.skip(
description: 'CAT-1018',
},
},
async ({ api, n8nContainer }) => {
async ({ api, n8nContainer, services }) => {
test.setTimeout(300000);
// ========== SETUP: Verify Initial Health ==========
@@ -44,7 +44,7 @@ test.skip(
// ========== WAIT FOR CONNECTION ISSUES ==========
// Query VictoriaLogs for database timeout messages
await n8nContainer.logs.waitForLog('Database connection timed out', {
await services.observability.logs.waitForLog('Database connection timed out', {
timeoutMs: 20 * Time.seconds.toMilliseconds,
start: '-1m',
});
@@ -64,7 +64,7 @@ test.skip(
// ========== VERIFY: Automatic Recovery ==========
// Query VictoriaLogs for database recovery messages
await n8nContainer.logs.waitForLog('Database connection recovered', {
await services.observability.logs.waitForLog('Database connection recovered', {
timeoutMs: 20 * Time.seconds.toMilliseconds,
start: '-1m',
});
@@ -60,8 +60,8 @@ test.describe('Multi-main Observability @capability:observability @mode:multi-ma
* This tests the Prometheus-compatible /metrics endpoint exposure and
* service discovery configuration in VictoriaMetrics.
*/
test('should scrape metrics from all n8n instances', async ({ n8nContainer }) => {
const obs = n8nContainer.services.observability;
test('should scrape metrics from all n8n instances', async ({ services }) => {
const obs = services.observability;
// Expected targets: 2 mains + 1 worker = 3 instances
const expectedTargets = 3;
@@ -108,11 +108,11 @@ test.describe('Multi-main Observability @capability:observability @mode:multi-ma
* - TCP syslog delivery from n8n to VictoriaLogs
* - LogsQL query capability in VictoriaLogs
*/
test('should configure log streaming and receive events', async ({ api, n8nContainer }) => {
test('should configure log streaming and receive events', async ({ api, services }) => {
// ========== STEP 1: Enable log streaming feature ==========
await api.enableFeature('logStreaming');
const obs = n8nContainer.services.observability;
const obs = services.observability;
// ========== STEP 2: Configure syslog destination ==========
// Create a syslog destination pointing to VictoriaLogs
@@ -11,8 +11,9 @@ test.use({
test.describe('Memory Consumption @capability:observability', () => {
test('Memory consumption baseline with starter plan resources', async ({
n8nContainer,
services,
}, testInfo) => {
const obs = n8nContainer.services.observability;
const obs = services.observability;
const { heapUsedMB } = await getStableHeap(n8nContainer.baseUrl, obs.metrics);
@@ -15,8 +15,12 @@ test.describe('Memory Leak Detection @capability:observability', () => {
await n8n.navigate.toWorkflows();
}
test('Memory should be released after actions', async ({ n8nContainer, n8n }, testInfo) => {
const obs = n8nContainer.services.observability;
test('Memory should be released after actions', async ({
n8nContainer,
n8n,
services,
}, testInfo) => {
const obs = services.observability;
const baseline = await getStableHeap(n8nContainer.baseUrl, obs.metrics);
await performMemoryAction(n8n);
+5 -2
View File
@@ -3673,6 +3673,9 @@ importers:
kafkajs:
specifier: 'catalog:'
version: 2.2.4
mockserver-client:
specifier: ^5.15.0
version: 5.15.0
testcontainers:
specifier: ^11.11.0
version: 11.11.0
@@ -31409,7 +31412,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.3
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.12.0(debug@4.4.3))
retry-axios: 2.6.0(axios@1.12.0)
tough-cookie: 4.1.4
transitivePeerDependencies:
- supports-color
@@ -35934,7 +35937,7 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
retry-axios@2.6.0(axios@1.12.0(debug@4.4.3)):
retry-axios@2.6.0(axios@1.12.0):
dependencies:
axios: 1.12.0(debug@4.4.3)