first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,95 @@
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import { EXTERNAL_HOST, type Service, type ServiceResult, type StartContext } from './types';
export interface CloudflaredMeta {
publicUrl: string;
proxyHops: number;
}
export type CloudflaredResult = ServiceResult<CloudflaredMeta>;
const METRICS_PORT = 2000;
function getTunnelTarget(ctx: StartContext): string {
if (ctx.external) {
return `${EXTERNAL_HOST}:5678`;
}
if (ctx.needsLoadBalancer) {
return `${ctx.projectName}-caddy-lb:80`;
}
return `${ctx.projectName}-n8n:5678`;
}
export const cloudflared: Service<CloudflaredResult> = {
description: 'Cloudflare Tunnel',
dependsOn: ['loadBalancer'],
shouldStart: (ctx) => ctx.config.services?.includes('cloudflared') ?? false,
getOptions(ctx) {
const proxyHops = ctx.needsLoadBalancer ? 2 : 1;
return { tunnelTarget: getTunnelTarget(ctx), proxyHops };
},
env(result) {
return {
WEBHOOK_URL: result.meta.publicUrl,
N8N_PROXY_HOPS: String(result.meta.proxyHops),
};
},
async start(
network,
projectName,
config?: unknown,
ctx?: StartContext,
): Promise<CloudflaredResult> {
const { tunnelTarget, proxyHops } = config as { tunnelTarget: string; proxyHops: number };
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
let builder = new GenericContainer(TEST_CONTAINER_IMAGES.cloudflared)
.withNetwork(network)
.withNetworkAliases('cloudflared')
.withName(`${projectName}-cloudflared`)
.withExposedPorts(METRICS_PORT)
.withCommand([
'tunnel',
'--url',
`http://${tunnelTarget}`,
'--metrics',
`0.0.0.0:${METRICS_PORT}`,
'--no-autoupdate',
])
.withWaitStrategy(Wait.forHttp('/quicktunnel', METRICS_PORT).forStatusCode(200))
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'cloudflared',
})
.withReuse()
.withLogConsumer(consumer);
// On Linux, host.docker.internal is not available without explicit mapping
if (ctx?.external) {
builder = builder.withExtraHosts([{ host: EXTERNAL_HOST, ipAddress: 'host-gateway' }]);
}
const container = await builder.start();
const hostPort = container.getMappedPort(METRICS_PORT);
const response = await fetch(`http://${container.getHost()}:${hostPort}/quicktunnel`);
const data = (await response.json()) as { hostname: string };
const publicUrl = `https://${data.hostname}`;
return {
container,
meta: { publicUrl, proxyHops },
};
} catch (error) {
return throwWithLogs(error);
}
},
};
@@ -0,0 +1,258 @@
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'gitea';
const HTTP_PORT = 3000;
const SSH_PORT = 22;
const DEFAULT_ADMIN = 'giteaadmin';
const DEFAULT_PASSWORD = 'giteapassword';
const DEFAULT_EMAIL = 'admin@example.com';
const DEFAULT_REPO = 'n8n-test-repo';
const DEFAULT_BRANCHES = ['development', 'staging', 'production'];
export interface GiteaMeta {
apiUrl: string;
adminUsername: string;
adminPassword: string;
defaultRepo: string;
}
export type GiteaResult = ServiceResult<GiteaMeta>;
export const gitea: Service<GiteaResult> = {
description: 'Git server (Gitea)',
async start(network: StartedNetwork, projectName: string): Promise<GiteaResult> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.gitea)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(HTTP_PORT, SSH_PORT)
.withEnvironment({
GITEA__database__DB_TYPE: 'sqlite3',
GITEA__server__DOMAIN: HOSTNAME,
GITEA__server__ROOT_URL: `http://${HOSTNAME}:${HTTP_PORT}/`,
GITEA__server__SSH_DOMAIN: HOSTNAME,
GITEA__security__INSTALL_LOCK: 'true',
GITEA__security__SECRET_KEY: 'gitea-test-secret-key',
GITEA__service__DISABLE_REGISTRATION: 'true',
})
.withWaitStrategy(Wait.forListeningPorts())
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.withLogConsumer(consumer)
.start();
// Setup admin user and default repo
await addUser(container, DEFAULT_ADMIN, DEFAULT_PASSWORD, DEFAULT_EMAIL, true);
await addRepo(container, DEFAULT_REPO, DEFAULT_ADMIN, DEFAULT_PASSWORD);
// Create default branches
for (const branch of DEFAULT_BRANCHES) {
await addBranch(container, DEFAULT_REPO, branch, DEFAULT_ADMIN, DEFAULT_PASSWORD);
}
return {
container,
meta: {
apiUrl: `http://${container.getHost()}:${container.getMappedPort(HTTP_PORT)}`,
adminUsername: DEFAULT_ADMIN,
adminPassword: DEFAULT_PASSWORD,
defaultRepo: DEFAULT_REPO,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
env(result: GiteaResult, external?: boolean): Record<string, string> {
return {
N8N_SOURCECONTROL_HOST: external ? result.meta.apiUrl : `http://${HOSTNAME}:${HTTP_PORT}`,
};
},
};
async function addUser(
container: StartedTestContainer,
username: string,
password: string,
email: string,
admin = false,
): Promise<void> {
const adminFlag = admin ? '--admin' : '';
await container.exec([
'bash',
'-c',
`cd /data/gitea && su git -c "/usr/local/bin/gitea admin user create --config /data/gitea/conf/app.ini --username ${username} --password ${password} --email ${email} ${adminFlag} --must-change-password=false"`,
]);
}
async function addRepo(
container: StartedTestContainer,
repoName: string,
username: string,
password: string,
): Promise<void> {
await container.exec([
'curl',
'-X',
'POST',
`http://localhost:${HTTP_PORT}/api/v1/user/repos`,
'-H',
'Content-Type: application/json',
'-u',
`${username}:${password}`,
'-d',
`{"name":"${repoName}","private":false,"auto_init":true}`,
]);
}
async function addBranch(
container: StartedTestContainer,
repoName: string,
branchName: string,
username: string,
password: string,
fromBranch = 'main',
): Promise<void> {
await container.exec([
'curl',
'-X',
'POST',
`http://localhost:${HTTP_PORT}/api/v1/repos/${username}/${repoName}/branches`,
'-H',
'Content-Type: application/json',
'-u',
`${username}:${password}`,
'-d',
`{"new_branch_name":"${branchName}","old_branch_name":"${fromBranch}"}`,
]);
}
export class GiteaHelper {
private readonly container: StartedTestContainer;
private readonly meta: GiteaMeta;
constructor(container: StartedTestContainer, meta: GiteaMeta) {
this.container = container;
this.meta = meta;
}
get apiUrl(): string {
return this.meta.apiUrl;
}
get adminUsername(): string {
return this.meta.adminUsername;
}
get adminPassword(): string {
return this.meta.adminPassword;
}
get defaultRepo(): string {
return this.meta.defaultRepo;
}
async createUser(
username: string,
password: string,
email: string,
admin = false,
): Promise<void> {
await addUser(this.container, username, password, email, admin);
}
async createRepo(repoName: string, username?: string, password?: string): Promise<void> {
await addRepo(
this.container,
repoName,
username ?? this.meta.adminUsername,
password ?? this.meta.adminPassword,
);
}
async createBranch(
repoName: string,
branchName: string,
username?: string,
password?: string,
fromBranch = 'main',
): Promise<void> {
await addBranch(
this.container,
repoName,
branchName,
username ?? this.meta.adminUsername,
password ?? this.meta.adminPassword,
fromBranch,
);
}
async addSSHKey(
keyTitle: string,
publicKey: string,
username?: string,
password?: string,
): Promise<void> {
await this.container.exec([
'curl',
'-X',
'POST',
`http://localhost:${HTTP_PORT}/api/v1/user/keys`,
'-H',
'Content-Type: application/json',
'-u',
`${username ?? this.meta.adminUsername}:${password ?? this.meta.adminPassword}`,
'-d',
`{"title":"${keyTitle}","key":"${publicKey}","read_only":false}`,
]);
}
async commitExists(
repoName: string,
commitHash: string,
username?: string,
password?: string,
): Promise<boolean> {
const result = await this.container.exec([
'curl',
'-s',
'-o',
'/dev/null',
'-w',
'%{http_code}',
`http://localhost:${HTTP_PORT}/api/v1/repos/${username ?? this.meta.adminUsername}/${repoName}/git/commits/${commitHash}`,
'-u',
`${username ?? this.meta.adminUsername}:${password ?? this.meta.adminPassword}`,
]);
// curl writes HTTP status code to stdout, 200 means commit exists
const statusCode = result.output.trim();
return statusCode === '200';
}
}
export function createGiteaHelper(ctx: HelperContext): GiteaHelper {
const result = ctx.serviceResults.gitea as GiteaResult | undefined;
if (!result) {
throw new Error('Gitea service not found in context');
}
return new GiteaHelper(result.container, result.meta);
}
declare module './types' {
interface ServiceHelpers {
gitea: GiteaHelper;
}
}
@@ -0,0 +1,187 @@
import { KafkaContainer, type StartedKafkaContainer } from '@testcontainers/kafka';
import { Kafka, type Producer, type EachMessagePayload } from 'kafkajs';
import type { StartedNetwork } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'kafka';
export interface KafkaMeta {
internalBroker: string;
externalBroker: string;
}
export type KafkaResult = ServiceResult<KafkaMeta> & {
container: StartedKafkaContainer;
};
export const kafka: Service<KafkaResult> = {
description: 'Apache Kafka broker for message queue testing',
async start(network: StartedNetwork, projectName: string): Promise<KafkaResult> {
const container = await new KafkaContainer(TEST_CONTAINER_IMAGES.kafka)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withKraft()
.withReuse()
.start();
return {
container,
meta: {
internalBroker: `${HOSTNAME}:9092`,
externalBroker: `${container.getHost()}:${container.getMappedPort(9093)}`,
},
};
},
env(result: KafkaResult, external?: boolean): Record<string, string> {
if (!external) return {};
return {
KAFKA_BROKER: result.meta.externalBroker,
};
},
};
export class KafkaHelper {
private readonly kafka: Kafka;
private producer: Producer | null = null;
constructor(broker: string) {
this.kafka = new Kafka({
clientId: 'n8n-test-helper',
brokers: [broker],
});
}
async createTopic(topic: string, numPartitions = 1): Promise<void> {
const admin = this.kafka.admin();
try {
await admin.connect();
await admin.createTopics({
topics: [{ topic, numPartitions }],
});
} finally {
await admin.disconnect();
}
}
async waitForConsumerGroup(
groupId: string,
options: { timeoutMs?: number; pollIntervalMs?: number } = {},
): Promise<void> {
const { timeoutMs = 10000, pollIntervalMs = 500 } = options;
const admin = this.kafka.admin();
const deadline = Date.now() + timeoutMs;
try {
await admin.connect();
while (Date.now() < deadline) {
const groups = await admin.describeGroups([groupId]);
const group = groups.groups[0];
if (group && group.state === 'Stable' && group.members.length > 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`Consumer group '${groupId}' did not become active within ${timeoutMs}ms`);
} finally {
await admin.disconnect();
}
}
async publish(topic: string, message: string | object, key?: string): Promise<void> {
if (!this.producer) {
this.producer = this.kafka.producer();
await this.producer.connect();
}
const value = typeof message === 'string' ? message : JSON.stringify(message);
await this.producer.send({
topic,
messages: [{ key, value }],
});
}
async consume(
topic: string,
options: {
groupId?: string;
maxMessages?: number;
timeoutMs?: number;
fromBeginning?: boolean;
} = {},
): Promise<Array<{ key: string | null; value: string; partition: number; offset: string }>> {
const {
groupId = `test-consumer-${Date.now()}`,
maxMessages = 10,
timeoutMs = 5000,
fromBeginning = true,
} = options;
const consumer = this.kafka.consumer({ groupId });
const messages: Array<{
key: string | null;
value: string;
partition: number;
offset: string;
}> = [];
try {
await consumer.connect();
await consumer.subscribe({ topic, fromBeginning });
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), timeoutMs);
void consumer.run({
// kafkajs requires async handler signature, but we don't need to await anything
// eslint-disable-next-line @typescript-eslint/require-await
eachMessage: async ({ message, partition }: EachMessagePayload) => {
messages.push({
key: message.key?.toString() ?? null,
value: message.value?.toString() ?? '',
partition,
offset: message.offset,
});
if (messages.length >= maxMessages) {
clearTimeout(timeout);
resolve();
}
},
});
});
} finally {
await consumer.disconnect();
}
return messages;
}
}
export function createKafkaHelper(ctx: HelperContext): KafkaHelper {
const result = ctx.serviceResults.kafka as KafkaResult | undefined;
if (!result) {
throw new Error('Kafka service not found in context');
}
return new KafkaHelper(result.meta.externalBroker);
}
declare module './types' {
interface ServiceHelpers {
kafka: KafkaHelper;
}
}
@@ -0,0 +1,176 @@
/**
* Kent - Sentry's mock server for testing SDK integrations
* @see https://github.com/getsentry/kent
*/
import { resolve } from 'node:path';
import { GenericContainer, Wait } from 'testcontainers';
import type { StartedNetwork } from 'testcontainers';
import type { HelperContext, Service, ServiceResult, ServiceMeta } from './types';
const HOSTNAME = 'kent';
const PORT = 8000;
const DOCKERFILE_PATH = resolve(__dirname, '../dockerfiles/kent');
export interface KentMeta extends ServiceMeta {
host: string;
port: number;
apiUrl: string;
sentryDsn: string;
frontendDsn: string;
}
export type KentResult = ServiceResult<KentMeta>;
export const kent: Service<KentResult> = {
description: 'Sentry mock server for testing',
async start(network: StartedNetwork, projectName: string): Promise<KentResult> {
const container = await GenericContainer.fromDockerfile(DOCKERFILE_PATH)
.build('n8n-kent:local', { deleteOnExit: false })
.then(
async (image) =>
await image
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(PORT)
.withWaitStrategy(Wait.forListeningPorts())
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.start(),
);
const mappedPort = container.getMappedPort(PORT);
const host = container.getHost();
return {
container,
meta: {
host: HOSTNAME,
port: PORT,
apiUrl: `http://${host}:${mappedPort}`,
sentryDsn: `http://testkey@${HOSTNAME}:${PORT}/1`,
frontendDsn: `http://testkey@${host}:${mappedPort}/1`,
},
};
},
env(result: KentResult): Record<string, string> {
return {
N8N_SENTRY_DSN: result.meta.sentryDsn,
N8N_FRONTEND_SENTRY_DSN: result.meta.frontendDsn,
N8N_SENTRY_TRACES_SAMPLE_RATE: '1.0',
ENVIRONMENT: 'test',
DEPLOYMENT_NAME: 'e2e-test-deployment',
};
},
};
// ==================== Types ====================
export type EventSource = 'backend' | 'frontend' | 'task_runner' | 'unknown';
export type EventType = 'error' | 'transaction' | 'session' | 'unknown';
export interface KentEventFilter {
source?: EventSource;
type?: EventType;
messageContains?: string;
}
export interface KentEvent {
event_id: string;
project_id: number;
payload: {
body: {
sdk?: { name: string; version: string };
platform?: string;
type?: string;
transaction?: string;
tags?: Record<string, string>;
user?: { id?: string; email?: string; username?: string; ip_address?: string };
exception?: { values: Array<{ type: string; value: string }> };
spans?: unknown[];
[key: string]: unknown;
};
};
}
// ==================== Helper ====================
export class KentHelper {
constructor(private readonly apiUrl: string) {}
async clear(): Promise<void> {
const res = await fetch(`${this.apiUrl}/api/flush/`, { method: 'POST' });
if (!res.ok) throw new Error(`Kent API error: ${res.status}`);
}
async getEvents(filter?: KentEventFilter): Promise<KentEvent[]> {
const res = await fetch(`${this.apiUrl}/api/eventlist/`);
if (!res.ok) throw new Error(`Kent API error: ${res.status}`);
const { events } = (await res.json()) as { events: Array<{ event_id: string }> };
const allEvents = await Promise.all(events.map(async (e) => await this.getEvent(e.event_id)));
if (!filter) return allEvents;
return allEvents.filter((event) => {
if (filter.source && this.getSource(event) !== filter.source) return false;
if (filter.type && this.getType(event) !== filter.type) return false;
if (filter.messageContains && !this.getErrorMessage(event).includes(filter.messageContains))
return false;
return true;
});
}
getSource(event: KentEvent): EventSource {
const sdk = event.payload.body.sdk?.name ?? '';
if (sdk.includes('vue') || sdk.includes('browser')) return 'frontend';
if (sdk === 'sentry.javascript.node' || event.payload.body.platform === 'node') {
return event.payload.body.tags?.server_type === 'task_runner' ? 'task_runner' : 'backend';
}
if ('sid' in event.payload.body) return 'frontend';
return 'unknown';
}
getType(event: KentEvent): EventType {
const body = event.payload.body;
if ('sid' in body) return 'session';
if (body.exception) return 'error';
if (body.type === 'transaction' || Array.isArray(body.spans)) return 'transaction';
return 'unknown';
}
getErrorMessage(event: KentEvent): string {
return event.payload.body.exception?.values?.[0]?.value ?? '';
}
getTags(event: KentEvent): Record<string, string> | undefined {
return event.payload.body.tags;
}
getUser(event: KentEvent): KentEvent['payload']['body']['user'] {
return event.payload.body.user;
}
private async getEvent(eventId: string): Promise<KentEvent> {
const res = await fetch(`${this.apiUrl}/api/event/${eventId}`);
if (!res.ok) throw new Error(`Kent API error: ${res.status}`);
return (await res.json()) as KentEvent;
}
}
export function createKentHelper(ctx: HelperContext): KentHelper {
const result = ctx.serviceResults.kent as KentResult | undefined;
if (!result) throw new Error('Kent service not found. Add "kent" to your services array.');
return new KentHelper(result.meta.apiUrl);
}
declare module './types' {
interface ServiceHelpers {
kent: KentHelper;
}
}
@@ -0,0 +1,540 @@
import getPort from 'get-port';
import { setTimeout as wait } from 'node:timers/promises';
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { Agent, request as undiciRequest } from 'undici';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { FileToMount, HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'keycloak';
const HTTPS_PORT = 8443;
const KEYCLOAK_TEST_REALM = 'test';
const KEYCLOAK_TEST_CLIENT_ID = 'n8n-e2e';
const KEYCLOAK_TEST_CLIENT_SECRET = 'n8n-test-secret';
const KEYCLOAK_TEST_USER_EMAIL = 'test@n8n.io';
const KEYCLOAK_TEST_USER_PASSWORD = 'testpassword';
const KEYCLOAK_TEST_USER_FIRSTNAME = 'Test';
const KEYCLOAK_TEST_USER_LASTNAME = 'User';
const KEYCLOAK_ADMIN_USER = 'admin';
const KEYCLOAK_ADMIN_PASSWORD = 'admin';
const KEYCLOAK_CERT_PATH = '/tmp/keycloak-ca.pem';
const N8N_KEYCLOAK_CERT_PATH = '/tmp/keycloak-ca.pem';
export interface KeycloakConfig {
n8nCallbackUrl: string;
}
export interface KeycloakMeta {
discoveryUrl: string;
internalDiscoveryUrl: string;
certPem: string;
hostPort: number;
clientId: string;
clientSecret: string;
testUser: {
email: string;
password: string;
firstName: string;
lastName: string;
};
n8nFilesToMount: FileToMount[];
}
export type KeycloakResult = ServiceResult<KeycloakMeta>;
function generateRealmJson(callbackUrl: string): string {
// Derive the n8n base URL from the OIDC callback URL
const n8nBaseUrl = callbackUrl.split('/rest/')[0];
return JSON.stringify({
realm: KEYCLOAK_TEST_REALM,
enabled: true,
sslRequired: 'none',
registrationAllowed: false,
loginWithEmailAllowed: true,
duplicateEmailsAllowed: false,
resetPasswordAllowed: false,
editUsernameAllowed: false,
bruteForceProtected: false,
clients: [
{
clientId: KEYCLOAK_TEST_CLIENT_ID,
enabled: true,
clientAuthenticatorType: 'client-secret',
secret: KEYCLOAK_TEST_CLIENT_SECRET,
redirectUris: [
callbackUrl,
`${callbackUrl}/*`,
// Allow the n8n OAuth2 credential callback for dynamic credential authorization flow
`${n8nBaseUrl}/rest/oauth2-credential/callback`,
],
webOrigins: ['*'],
standardFlowEnabled: true,
directAccessGrantsEnabled: true,
publicClient: false,
protocol: 'openid-connect',
},
],
users: [
{
username: 'testuser',
enabled: true,
email: KEYCLOAK_TEST_USER_EMAIL,
emailVerified: true,
firstName: KEYCLOAK_TEST_USER_FIRSTNAME,
lastName: KEYCLOAK_TEST_USER_LASTNAME,
credentials: [
{
type: 'password',
value: KEYCLOAK_TEST_USER_PASSWORD,
temporary: false,
},
],
},
],
});
}
/**
* Generates a shell script that creates a keystore with self-signed cert using Java keytool,
* exports the certificate to PEM format, and starts Keycloak with HTTPS.
*/
function generateStartupScript(): string {
return `#!/bin/bash
set -e
# Generate self-signed certificate using Java keytool (available in Keycloak image)
keytool -genkeypair \\
-storepass password \\
-storetype PKCS12 \\
-keyalg RSA \\
-keysize 2048 \\
-dname "CN=localhost" \\
-alias server \\
-ext "SAN=DNS:localhost,DNS:keycloak,IP:127.0.0.1" \\
-keystore /opt/keycloak/conf/server.keystore
# Export the certificate to PEM format for Node.js NODE_EXTRA_CA_CERTS
keytool -exportcert \\
-alias server \\
-keystore /opt/keycloak/conf/server.keystore \\
-rfc \\
-file ${KEYCLOAK_CERT_PATH} \\
-storepass password
exec /opt/keycloak/bin/kc.sh start-dev \\
--import-realm \\
--https-key-store-file=/opt/keycloak/conf/server.keystore \\
--https-key-store-password=password \\
--hostname=https://localhost:\${KEYCLOAK_HOST_PORT} \\
--hostname-backchannel-dynamic=true
`;
}
async function extractCertificate(
container: StartedTestContainer,
timeoutMs: number = 30000,
): Promise<string> {
const startTime = Date.now();
const retryIntervalMs = 500;
while (Date.now() - startTime < timeoutMs) {
try {
const certResult = await container.exec(['cat', KEYCLOAK_CERT_PATH]);
if (certResult.exitCode === 0 && certResult.output.includes('BEGIN CERTIFICATE')) {
return certResult.output;
}
} catch {
// Retry on error
}
await wait(retryIntervalMs);
}
throw new Error(
`Failed to extract Keycloak certificate from ${KEYCLOAK_CERT_PATH} within ${timeoutMs}ms`,
);
}
async function waitForKeycloakReady(
port: number,
certPem: string,
timeoutMs: number = 60000,
): Promise<void> {
const startTime = Date.now();
const url = `https://localhost:${port}/realms/${KEYCLOAK_TEST_REALM}/.well-known/openid-configuration`;
const retryIntervalMs = 2000;
const agent = new Agent({
connect: { ca: certPem },
});
try {
while (Date.now() - startTime < timeoutMs) {
try {
const response = await fetch(url, {
// @ts-expect-error - dispatcher is an undici-specific option
dispatcher: agent,
});
if (response.ok) {
return;
}
} catch {
// Retry on connection errors
}
await wait(retryIntervalMs);
}
throw new Error(
`Keycloak discovery endpoint at ${url} did not become ready within ${timeoutMs / 1000} seconds`,
);
} finally {
await agent.close();
}
}
export const keycloak: Service<KeycloakResult> = {
description: 'Keycloak OIDC provider',
getOptions(ctx) {
const port = ctx.allocatedPorts.loadBalancer ?? ctx.allocatedPorts.main;
return { n8nCallbackUrl: `http://localhost:${port}/rest/sso/oidc/callback` } as KeycloakConfig;
},
async verifyFromN8n(result, n8nContainers) {
const { setTimeout: wait } = await import('node:timers/promises');
const timeoutMs = 30000;
const retryIntervalMs = 1000;
for (const container of n8nContainers) {
const startTime = Date.now();
let verified = false;
while (Date.now() - startTime < timeoutMs) {
try {
const execResult = await container.exec([
'wget',
'--no-check-certificate',
'-q',
'-O',
'-',
result.meta.internalDiscoveryUrl,
]);
if (execResult.exitCode === 0) {
verified = true;
break;
}
} catch {
// Retry
}
await wait(retryIntervalMs);
}
if (!verified) {
throw new Error(
`Keycloak verification failed: ${container.getName()} could not reach ${result.meta.internalDiscoveryUrl} within ${timeoutMs}ms`,
);
}
}
},
async start(
network: StartedNetwork,
projectName: string,
config?: unknown,
): Promise<KeycloakResult> {
const { n8nCallbackUrl } = config as KeycloakConfig;
const { consumer, throwWithLogs } = createSilentLogConsumer();
// Allocate a fixed host port for Keycloak
const allocatedHostPort = await getPort();
const realmJson = generateRealmJson(n8nCallbackUrl);
const startupScript = generateStartupScript();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.keycloak)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts({ container: HTTPS_PORT, host: allocatedHostPort })
.withEnvironment({
KEYCLOAK_ADMIN: KEYCLOAK_ADMIN_USER,
KEYCLOAK_ADMIN_PASSWORD,
KC_HEALTH_ENABLED: 'true',
KC_METRICS_ENABLED: 'false',
KEYCLOAK_HOST_PORT: String(allocatedHostPort),
})
.withCopyContentToContainer([
{ content: realmJson, target: '/opt/keycloak/data/import/realm.json' },
{ content: startupScript, target: '/startup.sh', mode: 0o755 },
])
.withEntrypoint(['/bin/bash', '/startup.sh'])
.withWaitStrategy(
Wait.forLogMessage(/Running the server in development mode/).withStartupTimeout(120000),
)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withLogConsumer(consumer)
.withReuse()
.start();
const discoveryUrl = `https://localhost:${allocatedHostPort}/realms/${KEYCLOAK_TEST_REALM}/.well-known/openid-configuration`;
const internalDiscoveryUrl = `https://${HOSTNAME}:${HTTPS_PORT}/realms/${KEYCLOAK_TEST_REALM}/.well-known/openid-configuration`;
const certPem = await extractCertificate(container);
await waitForKeycloakReady(allocatedHostPort, certPem);
return {
container,
meta: {
discoveryUrl,
internalDiscoveryUrl,
certPem,
hostPort: allocatedHostPort,
clientId: KEYCLOAK_TEST_CLIENT_ID,
clientSecret: KEYCLOAK_TEST_CLIENT_SECRET,
testUser: {
email: KEYCLOAK_TEST_USER_EMAIL,
password: KEYCLOAK_TEST_USER_PASSWORD,
firstName: KEYCLOAK_TEST_USER_FIRSTNAME,
lastName: KEYCLOAK_TEST_USER_LASTNAME,
},
n8nFilesToMount: [{ content: certPem, target: N8N_KEYCLOAK_CERT_PATH }],
},
};
} catch (error) {
return throwWithLogs(error);
}
},
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`,
};
},
};
export class KeycloakHelper {
private readonly meta: KeycloakMeta;
constructor(_container: StartedTestContainer, meta: KeycloakMeta) {
this.meta = meta;
}
get discoveryUrl(): string {
return this.meta.discoveryUrl;
}
get internalDiscoveryUrl(): string {
return this.meta.internalDiscoveryUrl;
}
get certPem(): string {
return this.meta.certPem;
}
get hostPort(): number {
return this.meta.hostPort;
}
get realm(): string {
return KEYCLOAK_TEST_REALM;
}
get clientId(): string {
return this.meta.clientId;
}
get clientSecret(): string {
return this.meta.clientSecret;
}
get testUser() {
return this.meta.testUser;
}
/**
* Obtain an access token for a user via the Resource Owner Password Credentials (ROPC) grant.
* Keycloak's test realm has directAccessGrantsEnabled=true, so no browser redirect is needed.
*/
async getAccessToken(email: string, password: string): Promise<string> {
const tokenEndpoint = `https://localhost:${this.meta.hostPort}/realms/${KEYCLOAK_TEST_REALM}/protocol/openid-connect/token`;
const agent = new Agent({ connect: { ca: this.meta.certPem } });
const body = new URLSearchParams({
grant_type: 'password',
client_id: KEYCLOAK_TEST_CLIENT_ID,
client_secret: KEYCLOAK_TEST_CLIENT_SECRET,
username: email,
password,
scope: 'openid',
});
try {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
// @ts-expect-error - dispatcher is an undici-specific option
dispatcher: agent,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Keycloak token request failed (${response.status}): ${text}`);
}
const data = (await response.json()) as { access_token: string };
return data.access_token;
} finally {
await agent.close();
}
}
/**
* Programmatically completes the OAuth2 authorization code flow for the test user.
* Uses undici (with the Keycloak CA cert) to:
* 1. GET the Keycloak authorization page
* 2. Extract the login form action URL
* 3. POST test user credentials to Keycloak
*
* Returns the n8n OAuth2 callback URL (with `code` and `state` query params).
* The caller should then GET this URL using the n8n API request context (which holds
* the n8n session cookie) so that n8n exchanges the code for tokens and stores them.
*/
async completeAuthorizationCodeFlow(authorizationUrl: string): Promise<string> {
const agent = new Agent({ connect: { ca: this.meta.certPem } });
try {
// Step 1: GET the Keycloak authorization page (HTML with login form).
// Use undiciRequest to access raw set-cookie headers for forwarding.
const authPageResult = await undiciRequest(authorizationUrl, {
method: 'GET',
dispatcher: agent,
});
if (authPageResult.statusCode < 200 || authPageResult.statusCode >= 300) {
await authPageResult.body.text();
throw new Error(
`Failed to load Keycloak authorization page: HTTP ${authPageResult.statusCode}`,
);
}
// Extract session cookies from the response to forward with the login POST.
// Keycloak sets cookies like AUTH_SESSION_ID, KC_RESTART that are required
// for the login form submission to succeed.
const rawCookies = authPageResult.headers['set-cookie'];
const cookieHeader = (Array.isArray(rawCookies) ? rawCookies : [rawCookies])
.filter(Boolean)
.map((c) => (c as string).split(';')[0])
.join('; ');
const html = await authPageResult.body.text();
// Step 2: Extract the Keycloak login form action URL.
// Keycloak's login form action always contains 'login-actions/authenticate'.
const rawFormAction = html.match(/action="([^"]*login-actions\/authenticate[^"]*)"/)?.[1];
if (!rawFormAction) {
throw new Error('Could not find Keycloak login form action in authorization page HTML');
}
const formAction = rawFormAction.replace(/&amp;/g, '&');
// Step 3: POST credentials with session cookies — Keycloak responds with 302
// to the n8n callback URL. undiciRequest does NOT follow redirects by default.
const loginBody = new URLSearchParams({
username: this.meta.testUser.email,
password: this.meta.testUser.password,
});
const loginHeaders: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
};
if (cookieHeader) {
loginHeaders['Cookie'] = cookieHeader;
}
const { headers, body } = await undiciRequest(formAction, {
method: 'POST',
headers: loginHeaders,
body: loginBody.toString(),
dispatcher: agent,
});
// Consume the body to prevent resource leaks
await body.text();
const location = headers.location;
const redirectUrl = Array.isArray(location) ? location[0] : location;
if (!redirectUrl) {
throw new Error(
'Keycloak did not redirect after login. ' +
'Ensure the OAuth2 credential callback URL is registered in Keycloak redirectUris.',
);
}
return redirectUrl; // e.g. http://localhost:{n8n_port}/rest/oauth2-credential/callback?code=...&state=...
} finally {
await agent.close();
}
}
async waitForFromContainer(
n8nContainer: StartedTestContainer,
timeoutMs: number = 30000,
): Promise<void> {
const startTime = Date.now();
const retryIntervalMs = 1000;
while (Date.now() - startTime < timeoutMs) {
try {
const result = await n8nContainer.exec([
'wget',
'--no-check-certificate',
'-q',
'-O',
'-',
this.meta.internalDiscoveryUrl,
]);
if (result.exitCode === 0) {
return;
}
} catch {
// Retry on error
}
await wait(retryIntervalMs);
}
throw new Error(
`Keycloak discovery endpoint not reachable from n8n container within ${timeoutMs}ms: ${this.meta.internalDiscoveryUrl}`,
);
}
}
export function createKeycloakHelper(ctx: HelperContext): KeycloakHelper {
const result = ctx.serviceResults.keycloak as KeycloakResult | undefined;
if (!result) {
throw new Error('Keycloak service not found in context');
}
return new KeycloakHelper(result.container, result.meta);
}
declare module './types' {
interface ServiceHelpers {
keycloak: KeycloakHelper;
}
}
@@ -0,0 +1,105 @@
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult } from './types';
export interface LoadBalancerConfig {
mainCount: number;
hostPort?: number;
}
export interface LoadBalancerMeta {
hostPort: number;
baseUrl: string;
}
export type LoadBalancerResult = ServiceResult<LoadBalancerMeta>;
function buildCaddyConfig(upstreamServers: string[]): string {
const backends = upstreamServers.join(' ');
return `
:80 {
# Reverse proxy with load balancing
reverse_proxy ${backends} {
# Use first available backend for simpler debugging
lb_policy first
# Health check
health_uri /healthz
health_interval 10s
# Timeouts
transport http {
dial_timeout 60s
read_timeout 60s
write_timeout 60s
}
}
# Set max request body size
request_body {
max_size 50MB
}
}`;
}
export const loadBalancer: Service<LoadBalancerResult> = {
description: 'Caddy load balancer',
shouldStart: (ctx) => ctx.needsLoadBalancer,
getOptions(ctx) {
return {
mainCount: ctx.mains,
hostPort: ctx.allocatedPorts.loadBalancer,
} as LoadBalancerConfig;
},
env(result) {
return {
WEBHOOK_URL: result.meta.baseUrl,
N8N_PROXY_HOPS: '1',
};
},
async start(network, projectName, config?: unknown): Promise<LoadBalancerResult> {
const { mainCount, hostPort } = config as LoadBalancerConfig;
const { consumer, throwWithLogs } = createSilentLogConsumer();
// Generate upstream server addresses
const upstreamServers = Array.from(
{ length: mainCount },
(_, index) => `${projectName}-n8n-main-${index + 1}:5678`,
);
const caddyConfig = buildCaddyConfig(upstreamServers);
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.caddy)
.withNetwork(network)
.withExposedPorts(hostPort ? { container: 80, host: hostPort } : 80)
.withCopyContentToContainer([{ content: caddyConfig, target: '/etc/caddy/Caddyfile' }])
.withWaitStrategy(Wait.forListeningPorts())
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'caddy-lb',
})
.withName(`${projectName}-caddy-lb`)
.withReuse()
.withLogConsumer(consumer)
.start();
const actualHostPort = container.getMappedPort(80);
return {
container,
meta: {
hostPort: actualHostPort,
baseUrl: `http://localhost:${actualHostPort}`,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
};
@@ -0,0 +1,257 @@
import {
CreateSecretCommand,
DeleteSecretCommand,
GetSecretValueCommand,
ListSecretsCommand,
SecretsManagerClient as AwsSecretsManagerClient,
} from '@aws-sdk/client-secrets-manager';
import type { StartedNetwork } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'localstack';
const EDGE_PORT = 4566;
const DEFAULT_REGION = 'us-east-1';
export interface LocalStackMeta {
endpoint: string;
internalEndpoint: string;
}
export type LocalStackResult = ServiceResult<LocalStackMeta>;
interface LocalStackHealthResponse {
services?: Record<string, string>;
}
export const localstack: Service<LocalStackResult> = {
description: 'AWS service emulator (LocalStack)',
async start(network: StartedNetwork, projectName: string): Promise<LocalStackResult> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.localstack)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(EDGE_PORT)
.withEnvironment({
SERVICES: 'secretsmanager',
DEFAULT_REGION,
// Disable LocalStack Pro features we don't need
SKIP_SSL_CERT_DOWNLOAD: '1',
})
.withWaitStrategy(
Wait.forAll([
Wait.forListeningPorts(),
Wait.forHttp('/_localstack/health', EDGE_PORT)
.forStatusCode(200)
.forResponsePredicate((body: string) => {
try {
const health = JSON.parse(body) as LocalStackHealthResponse;
// LocalStack returns 'available' for ready services
return health.services?.secretsmanager === 'available';
} catch {
return false;
}
})
.withStartupTimeout(120000),
]),
)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.withLogConsumer(consumer)
.start();
const hostPort = container.getMappedPort(EDGE_PORT);
return {
container,
meta: {
endpoint: `http://${container.getHost()}:${hostPort}`,
internalEndpoint: `http://${HOSTNAME}:${EDGE_PORT}`,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
env(result: LocalStackResult, external?: boolean): Record<string, string> {
return {
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,
};
},
};
/**
* Client for interacting with AWS Secrets Manager via LocalStack.
* Uses the official AWS SDK for proper API compatibility.
*/
export class SecretsManagerClient {
private readonly client: AwsSecretsManagerClient;
constructor(endpoint: string) {
this.client = new AwsSecretsManagerClient({
endpoint,
region: DEFAULT_REGION,
credentials: {
accessKeyId: 'test',
secretAccessKey: 'test',
},
});
}
/**
* Create a secret.
* @param name - Secret name
* @param value - Secret value (string or object that will be JSON-serialized)
*/
async createSecret(name: string, value: string | Record<string, unknown>): Promise<void> {
const secretString = typeof value === 'string' ? value : JSON.stringify(value);
await this.client.send(
new CreateSecretCommand({
Name: name,
SecretString: secretString,
}),
);
}
/**
* Get a secret value.
* @param name - Secret name
* @returns The secret string value
*/
async getSecret(name: string): Promise<string> {
const response = await this.client.send(
new GetSecretValueCommand({
SecretId: name,
}),
);
if (!response.SecretString) {
throw new Error(`Secret '${name}' has no string value`);
}
return response.SecretString;
}
/**
* Delete a secret.
* @param name - Secret name
* @param forceDelete - If true, deletes immediately without recovery window
*/
async deleteSecret(name: string, forceDelete = true): Promise<void> {
try {
await this.client.send(
new DeleteSecretCommand({
SecretId: name,
ForceDeleteWithoutRecovery: forceDelete,
}),
);
} catch (error) {
// Ignore "not found" errors during cleanup
if (error instanceof Error && error.name !== 'ResourceNotFoundException') {
throw error;
}
}
}
/**
* List all secret names.
* @returns Array of secret names
*/
async listSecrets(): Promise<string[]> {
const names: string[] = [];
let nextToken: string | undefined;
do {
const response = await this.client.send(
new ListSecretsCommand({
NextToken: nextToken,
}),
);
for (const secret of response.SecretList ?? []) {
if (secret.Name) names.push(secret.Name);
}
nextToken = response.NextToken;
} while (nextToken);
return names;
}
/**
* Delete all secrets. Useful for cleanup between tests.
*/
async clear(): Promise<void> {
const secrets = await this.listSecrets();
await Promise.all(secrets.map(async (name) => await this.deleteSecret(name)));
}
/**
* Wait for a secret to exist. Useful for eventual consistency scenarios.
* @param name - Secret name to wait for
* @param options - Timeout and polling options
* @returns The secret value once it exists
*/
async waitForSecret(
name: string,
options: { timeoutMs?: number; pollMs?: number } = {},
): Promise<string> {
const { timeoutMs = 10000, pollMs = 200 } = options;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
return await this.getSecret(name);
} catch {
// Secret doesn't exist yet, keep polling
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(`Secret '${name}' not found within ${timeoutMs}ms`);
}
}
/**
* Helper for interacting with LocalStack AWS services in tests.
*
* Access individual services via properties:
* - `localstack.secretsManager` - AWS Secrets Manager operations
*
* Future services can be added as needed (S3, SQS, etc.)
*/
export class LocalStackHelper {
readonly secretsManager: SecretsManagerClient;
constructor(endpoint: string) {
this.secretsManager = new SecretsManagerClient(endpoint);
}
}
export function createLocalStackHelper(ctx: HelperContext): LocalStackHelper {
const result = ctx.serviceResults.localstack as LocalStackResult | undefined;
if (!result) {
throw new Error('LocalStack service not found in context');
}
return new LocalStackHelper(result.meta.endpoint);
}
declare module './types' {
interface ServiceHelpers {
localstack: LocalStackHelper;
}
}
@@ -0,0 +1,230 @@
import type { StartedNetwork } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'mailpit';
const SMTP_PORT = 1025;
const HTTP_PORT = 8025;
type MailpitAddress = {
Address: string;
Name?: string;
};
export type MailpitMessageSummary = {
ID: string;
MessageID: string;
Read: boolean;
From: MailpitAddress;
To: MailpitAddress[];
Cc: MailpitAddress[] | null;
Bcc: MailpitAddress[] | null;
ReplyTo: MailpitAddress[];
Subject: string;
Created: string;
Username: string;
Tags: string[];
Size: number;
Attachments: number;
Snippet: string;
};
export type MailpitMessage = MailpitMessageSummary & {
Text?: string;
HTML?: string;
Inline?: Array<{
PartID: string;
FileName: string;
ContentType: string;
ContentID: string;
Size: number;
}>;
Attachments?: Array<{
PartID: string;
FileName: string;
ContentType: string;
ContentID: string;
Size: number;
}>;
};
export type MailpitQuery = {
to?: string | RegExp;
subject?: string | RegExp;
};
type MailpitListResponse = {
total: number;
unread: number;
count: number;
messages_count: number;
messages_unread: number;
start: number;
tags: string[];
messages: MailpitMessageSummary[];
};
export interface MailpitMeta {
apiBaseUrl: string;
}
export type MailpitResult = ServiceResult<MailpitMeta>;
export const mailpit: Service<MailpitResult> = {
description: 'Email testing server',
async start(network: StartedNetwork, projectName: string): Promise<MailpitResult> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.mailpit)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(SMTP_PORT, HTTP_PORT)
.withEnvironment({
MP_UI_BIND_ADDR: `0.0.0.0:${HTTP_PORT}`,
MP_SMTP_BIND_ADDR: `0.0.0.0:${SMTP_PORT}`,
})
.withWaitStrategy(
Wait.forAll([
Wait.forListeningPorts(),
Wait.forHttp('/api/v1/info', HTTP_PORT).forStatusCode(200).withStartupTimeout(30000),
]),
)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.withLogConsumer(consumer)
.start();
return {
container,
meta: {
apiBaseUrl: `http://${container.getHost()}:${container.getMappedPort(HTTP_PORT)}`,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
env(result: MailpitResult, external?: boolean): Record<string, string> {
return {
N8N_EMAIL_MODE: 'smtp',
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',
};
},
};
export class MailpitHelper {
private readonly 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> {
const res = await fetch(`${this.apiBaseUrl}/api/v1/messages`, { method: 'DELETE' });
if (!res.ok) {
throw new Error(`Mailpit clear failed: ${res.status} ${res.statusText}`);
}
}
async list(): Promise<MailpitMessageSummary[]> {
const res = await fetch(`${this.apiBaseUrl}/api/v1/messages`);
if (!res.ok) {
throw new Error(`Mailpit list failed: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as MailpitListResponse;
return data.messages || [];
}
async get(id: string): Promise<MailpitMessage> {
const res = await fetch(`${this.apiBaseUrl}/api/v1/message/${id}`);
if (!res.ok) {
throw new Error(`Mailpit get failed: ${res.status} ${res.statusText}`);
}
return (await res.json()) as MailpitMessage;
}
async waitForMessage(
query: MailpitQuery,
options: { timeoutMs?: number; pollMs?: number } = {},
): Promise<MailpitMessageSummary> {
const { timeoutMs = 10000, pollMs = 200 } = options;
const deadline = Date.now() + timeoutMs;
const messageMatches = (message: MailpitMessageSummary): boolean => {
if (query.to) {
const hasMatchingRecipient = message.To.some((recipient) =>
typeof query.to === 'string'
? recipient.Address === query.to
: query.to!.test(recipient.Address),
);
if (!hasMatchingRecipient) return false;
}
if (query.subject) {
const subjectMatches =
typeof query.subject === 'string'
? message.Subject === query.subject
: query.subject.test(message.Subject);
if (!subjectMatches) return false;
}
return true;
};
while (Date.now() < deadline) {
const messages = await this.list();
const match = messages.find(messageMatches);
if (match) {
return match;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
const queryParts = [];
if (query.to) queryParts.push(`to: ${query.to}`);
if (query.subject) queryParts.push(`subject: ${query.subject}`);
throw new Error(`Mail not received within ${timeoutMs}ms. Query: ${queryParts.join(', ')}`);
}
}
export function createMailpitHelper(ctx: HelperContext): MailpitHelper {
const result = ctx.serviceResults.mailpit as MailpitResult | undefined;
if (!result) {
throw new Error('Mailpit service not found in context');
}
return new MailpitHelper(result.meta.apiBaseUrl);
}
declare module './types' {
interface ServiceHelpers {
mailpit: MailpitHelper;
}
}
@@ -0,0 +1,68 @@
import { MySqlContainer, type StartedMySqlContainer } from '@testcontainers/mysql';
import type { StartedNetwork } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult } from './types';
const HOSTNAME = 'mysql';
export interface MySqlMeta {
database: string;
username: string;
password: string;
port: number;
internalHost: string;
externalHost: string;
externalPort: number;
}
export type MySqlResult = ServiceResult<MySqlMeta> & {
container: StartedMySqlContainer;
};
export const mysqlService: Service<MySqlResult> = {
description: 'MySQL database for integration testing',
async start(network: StartedNetwork, projectName: string): Promise<MySqlResult> {
const container = await new MySqlContainer(TEST_CONTAINER_IMAGES.mysql)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withDatabase('n8n_test')
.withUsername('n8n_user')
.withRootPassword('root_password')
.withUserPassword('test_password')
.withStartupTimeout(60_000)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.start();
return {
container,
meta: {
database: container.getDatabase(),
username: container.getUsername(),
password: container.getUserPassword(),
port: 3306,
internalHost: HOSTNAME,
externalHost: container.getHost(),
externalPort: container.getPort(),
},
};
},
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,
};
},
};
+242
View File
@@ -0,0 +1,242 @@
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { DockerImageNotFoundError } from '../docker-image-not-found-error';
import { createElapsedLogger, createSilentLogConsumer } from '../helpers/utils';
import { N8nImagePullPolicy } from '../n8n-image-pull-policy';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { FileToMount } from './types';
const N8N_IMAGE = TEST_CONTAINER_IMAGES.n8n;
const BASE_ENV: Record<string, string> = {
N8N_LOG_LEVEL: 'debug',
N8N_ENCRYPTION_KEY: process.env.N8N_ENCRYPTION_KEY ?? 'test-encryption-key',
E2E_TESTS: 'false',
QUEUE_HEALTH_CHECK_ACTIVE: 'true',
N8N_DIAGNOSTICS_ENABLED: 'false',
N8N_METRICS: 'true',
NODE_ENV: 'development',
N8N_DYNAMIC_BANNERS_ENABLED: 'false',
N8N_LICENSE_TENANT_ID: process.env.N8N_LICENSE_TENANT_ID ?? '1001',
N8N_LICENSE_ACTIVATION_KEY: process.env.N8N_LICENSE_ACTIVATION_KEY ?? '',
N8N_LICENSE_CERT: process.env.N8N_LICENSE_CERT ?? '',
N8N_RUNNERS_MODE: 'external',
N8N_RUNNERS_AUTH_TOKEN: 'test',
N8N_RUNNERS_BROKER_LISTEN_ADDRESS: '0.0.0.0',
// Expose V8 garbage collector for memory profiling in performance tests
NODE_OPTIONS: '--expose-gc',
};
const MAIN_WAIT_STRATEGY = Wait.forAll([
Wait.forListeningPorts(),
Wait.forHttp('/healthz/readiness', 5678).forStatusCode(200).withStartupTimeout(30000),
Wait.forLogMessage('Editor is now accessible via').withStartupTimeout(30000),
]);
const WORKER_WAIT_STRATEGY = Wait.forAll([
Wait.forListeningPorts(),
Wait.forLogMessage('n8n worker is now ready').withStartupTimeout(30000),
]);
export interface N8NInstancesOptions {
mains: number;
workers: number;
projectName: string;
network: StartedNetwork;
serviceEnvironment: Record<string, string>;
userEnvironment?: Record<string, string>;
usePostgres: boolean;
baseUrl?: string;
allocatedPort?: number;
resourceQuota?: { memory?: number; cpu?: number };
filesToMount?: FileToMount[];
}
export interface N8NInstancesResult {
containers: StartedTestContainer[];
environment: Record<string, string>;
}
function computeEnvironment(options: N8NInstancesOptions): Record<string, string> {
const {
mains,
workers,
usePostgres,
baseUrl,
serviceEnvironment,
userEnvironment = {},
} = options;
const isQueueMode = mains > 1 || workers > 0;
const env: Record<string, string> = {
...BASE_ENV,
...serviceEnvironment,
...userEnvironment,
};
if (!usePostgres) {
env.DB_TYPE = 'sqlite';
}
if (isQueueMode) {
env.EXECUTIONS_MODE = 'queue';
env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS = 'true';
if (mains > 1) {
if (!process.env.N8N_LICENSE_ACTIVATION_KEY && !process.env.N8N_LICENSE_CERT) {
throw new Error(
'N8N_LICENSE_ACTIVATION_KEY or N8N_LICENSE_CERT is required for multi-main instances',
);
}
env.N8N_MULTI_MAIN_SETUP_ENABLED = 'true';
}
}
if (mains === 1 && baseUrl && !serviceEnvironment.WEBHOOK_URL) {
env.WEBHOOK_URL = baseUrl;
env.N8N_PORT = '5678';
}
return env;
}
interface InstanceConfig {
name: string;
isWorker: boolean;
instanceNumber: number;
networkAlias?: string;
hostPort?: number;
}
interface SharedConfig {
projectName: string;
environment: Record<string, string>;
network: StartedNetwork;
resourceQuota?: { memory?: number; cpu?: number };
filesToMount?: FileToMount[];
}
async function createContainer(
instance: InstanceConfig,
shared: SharedConfig,
): Promise<StartedTestContainer> {
const { name, isWorker, instanceNumber, networkAlias, hostPort } = instance;
const { projectName, environment, network, resourceQuota, filesToMount } = shared;
const { consumer, throwWithLogs } = createSilentLogConsumer();
let container = new GenericContainer(N8N_IMAGE)
.withEnvironment(environment)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': isWorker ? 'n8n-worker' : 'n8n-main',
instance: instanceNumber.toString(),
})
.withPullPolicy(new N8nImagePullPolicy(N8N_IMAGE))
.withName(name)
.withLogConsumer(consumer)
.withReuse()
.withNetwork(network);
if (filesToMount?.length) {
container = container.withCopyContentToContainer(filesToMount);
}
if (resourceQuota) {
container = container.withResourcesQuota(resourceQuota);
}
if (networkAlias) {
container = container.withNetworkAliases(networkAlias);
}
const waitStrategy = isWorker ? WORKER_WAIT_STRATEGY : MAIN_WAIT_STRATEGY;
const ports = hostPort ? [{ container: 5678, host: hostPort }, 5679] : [5678, 5679];
container = container.withExposedPorts(...ports).withWaitStrategy(waitStrategy);
if (isWorker) {
container = container.withCommand(['worker']);
}
try {
return await container.start();
} catch (error: unknown) {
if (error instanceof Error && 'statusCode' in error) {
const statusCode = (error as Error & { statusCode: number }).statusCode;
if (statusCode === 404) {
throw new DockerImageNotFoundError(name, error);
}
}
console.error(`Container "${name}" failed to start:`, error);
return throwWithLogs(error);
}
}
export async function createN8NInstances(
options: N8NInstancesOptions,
): Promise<N8NInstancesResult> {
const { mains, workers, projectName, network, allocatedPort, resourceQuota, filesToMount } =
options;
const log = createElapsedLogger('n8n-instances');
const environment = computeEnvironment(options);
const containers: StartedTestContainer[] = [];
const shared: SharedConfig = {
projectName,
environment,
network,
resourceQuota,
filesToMount,
};
const instances: InstanceConfig[] = [
...Array.from({ length: mains }, (_, i) => {
const num = i + 1;
const name = mains > 1 ? `${projectName}-n8n-main-${num}` : `${projectName}-n8n`;
return {
name,
isWorker: false,
instanceNumber: num,
networkAlias: name,
hostPort: num === 1 ? allocatedPort : undefined,
};
}),
...Array.from({ length: workers }, (_, i) => ({
name: `${projectName}-n8n-worker-${i + 1}`,
isWorker: true,
instanceNumber: i + 1,
})),
];
// Service-only mode: no n8n containers needed
if (instances.length === 0) {
log('No n8n instances requested (service-only mode)');
return { containers, environment };
}
// Start main 1 first (handles DB migrations/setup)
const [main1, ...remaining] = instances;
log(`Starting main 1: ${main1.name} (DB setup)`);
containers.push(await createContainer(main1, shared));
log('main 1 ready');
// Start remaining instances in parallel
if (remaining.length > 0) {
log(`Starting ${remaining.length} remaining instances in parallel...`);
const parallelContainers = await Promise.all(
remaining.map(async (instance) => {
const type = instance.isWorker ? 'worker' : 'main';
log(`Starting ${type} ${instance.instanceNumber}: ${instance.name}`);
const container = await createContainer(instance, shared);
log(`${type} ${instance.instanceNumber} ready`);
return container;
}),
);
containers.push(...parallelContainers);
}
return { containers, environment };
}
@@ -0,0 +1,106 @@
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import { EXTERNAL_HOST, type Service, type ServiceResult, type StartContext } from './types';
export interface NgrokMeta {
publicUrl: string;
proxyHops: number;
}
export type NgrokResult = ServiceResult<NgrokMeta>;
const API_PORT = 4040;
function getTunnelTarget(ctx: StartContext): string {
if (ctx.external) {
return `${EXTERNAL_HOST}:5678`;
}
if (ctx.needsLoadBalancer) {
return `${ctx.projectName}-caddy-lb:80`;
}
return `${ctx.projectName}-n8n:5678`;
}
export const ngrok: Service<NgrokResult> = {
description: 'ngrok Tunnel',
dependsOn: ['loadBalancer'],
shouldStart: (ctx) => ctx.config.services?.includes('ngrok') ?? false,
getOptions(ctx) {
const proxyHops = ctx.needsLoadBalancer ? 2 : 1;
return { tunnelTarget: getTunnelTarget(ctx), proxyHops };
},
env(result) {
return {
WEBHOOK_URL: result.meta.publicUrl,
N8N_PROXY_HOPS: String(result.meta.proxyHops),
};
},
async start(network, projectName, config?: unknown, ctx?: StartContext): Promise<NgrokResult> {
const { tunnelTarget, proxyHops } = config as { tunnelTarget: string; proxyHops: number };
const { consumer, throwWithLogs } = createSilentLogConsumer();
const authToken = process.env.NGROK_AUTHTOKEN;
if (!authToken) {
throw new Error(
'NGROK_AUTHTOKEN environment variable is required. ' +
'Get a free token at https://dashboard.ngrok.com/get-started/your-authtoken',
);
}
try {
let builder = new GenericContainer(TEST_CONTAINER_IMAGES.ngrok)
.withNetwork(network)
.withNetworkAliases('ngrok')
.withName(`${projectName}-ngrok`)
.withExposedPorts(API_PORT)
.withEnvironment({
NGROK_AUTHTOKEN: authToken,
})
.withCommand(['http', `http://${tunnelTarget}`, '--log', 'stdout'])
.withWaitStrategy(Wait.forLogMessage(/started tunnel/i))
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'ngrok',
})
.withReuse()
.withLogConsumer(consumer);
// On Linux, host.docker.internal is not available without explicit mapping
if (ctx?.external) {
builder = builder.withExtraHosts([{ host: EXTERNAL_HOST, ipAddress: 'host-gateway' }]);
}
const container = await builder.start();
const hostPort = container.getMappedPort(API_PORT);
const host = container.getHost();
// ngrok API returns tunnel info at /api/tunnels
const response = await fetch(`http://${host}:${hostPort}/api/tunnels`);
const data = (await response.json()) as {
tunnels: Array<{ public_url: string; proto: string }>;
};
// Find the https tunnel
const httpsTunnel = data.tunnels.find((t) => t.proto === 'https');
const publicUrl = httpsTunnel?.public_url ?? data.tunnels[0]?.public_url;
if (!publicUrl) {
throw new Error('Failed to get ngrok public URL from API');
}
return {
container,
meta: { publicUrl, proxyHops },
};
} catch (error) {
return throwWithLogs(error);
}
},
};
@@ -0,0 +1,48 @@
/**
* Combined observability helper that provides unified access to logs and metrics.
* The actual services are in victoria-logs.ts and victoria-metrics.ts.
*/
import type { HelperContext } from './types';
import { LogsHelper, type VictoriaLogsResult, escapeLogsQL } from './victoria-logs';
import { MetricsHelper, type VictoriaMetricsResult } from './victoria-metrics';
export { escapeLogsQL };
export { LogsHelper, type LogEntry, type LogQueryOptions } from './victoria-logs';
export {
MetricsHelper,
type MetricResult,
type WaitForMetricOptions,
type ScrapeTarget,
} from './victoria-metrics';
export class ObservabilityHelper {
readonly logs: LogsHelper;
readonly metrics: MetricsHelper;
readonly syslog: VictoriaLogsResult['meta']['syslog'];
constructor(logsMeta: VictoriaLogsResult['meta'], metricsMeta: VictoriaMetricsResult['meta']) {
this.logs = new LogsHelper(logsMeta.queryEndpoint);
this.metrics = new MetricsHelper(metricsMeta.queryEndpoint);
this.syslog = logsMeta.syslog;
}
}
export function createObservabilityHelper(ctx: HelperContext): ObservabilityHelper {
const logsResult = ctx.serviceResults.victoriaLogs as VictoriaLogsResult | undefined;
const metricsResult = ctx.serviceResults.victoriaMetrics as VictoriaMetricsResult | undefined;
if (!logsResult) {
throw new Error('VictoriaLogs service not found in context');
}
if (!metricsResult) {
throw new Error('VictoriaMetrics service not found in context');
}
return new ObservabilityHelper(logsResult.meta, metricsResult.meta);
}
declare module './types' {
interface ServiceHelpers {
observability: ObservabilityHelper;
}
}
@@ -0,0 +1,68 @@
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import type { StartedNetwork } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult } from './types';
const HOSTNAME = 'postgres';
export interface PostgresMeta {
database: string;
username: string;
password: string;
}
export type PostgresResult = ServiceResult<PostgresMeta>;
export const postgres: Service<PostgresResult> = {
description: 'PostgreSQL database',
shouldStart: (ctx) => ctx.usePostgres,
async start(network: StartedNetwork, projectName: string): Promise<PostgresResult> {
const container = await new PostgreSqlContainer(TEST_CONTAINER_IMAGES.postgres)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withDatabase('n8n_db')
.withUsername('n8n_user')
.withPassword('test_password')
.withStartupTimeout(30000)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withAddedCapabilities('NET_ADMIN') // Allows us to drop IP tables and block traffic
.withTmpFs({ '/var/lib/postgresql': 'rw' })
.withCommand([
'postgres',
'-c',
'fsync=off',
'-c',
'synchronous_commit=off',
'-c',
'full_page_writes=off',
])
.withReuse()
.start();
return {
container,
meta: {
database: container.getDatabase(),
username: container.getUsername(),
password: container.getPassword(),
},
};
},
env(result: PostgresResult, external?: boolean): Record<string, string> {
return {
DB_TYPE: 'postgresdb',
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,
};
},
};
@@ -0,0 +1,372 @@
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 { HelperContext, Service, ServiceResult } from './types';
const HOSTNAME = 'proxyserver';
const PORT = 1080;
export interface ProxyMeta {
host: string;
port: number;
internalUrl: string;
}
export type ProxyResult = ServiceResult<ProxyMeta>;
export const proxy: Service<ProxyResult> = {
description: 'HTTP proxy server',
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: url,
HTTPS_PROXY: url,
NODE_TLS_REJECT_UNAUTHORIZED: '0',
};
},
async start(network, projectName): Promise<ProxyResult> {
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.mockserver)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withExposedPorts(PORT)
.withWaitStrategy(Wait.forLogMessage(`INFO ${PORT} started on port: ${PORT}`))
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.withLogConsumer(consumer)
.start();
return {
container,
meta: {
host: HOSTNAME,
port: PORT,
internalUrl: `http://${HOSTNAME}:${PORT}`,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
env(result: ProxyResult, external?: boolean): Record<string, string> {
return {
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;
}
}
@@ -0,0 +1,56 @@
import { RedisContainer } from '@testcontainers/redis';
import type { StartedNetwork } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult } from './types';
const HOSTNAME = 'redis';
export interface RedisMeta {
host: string;
port: number;
}
export type RedisResult = ServiceResult<RedisMeta>;
export const redis: Service<RedisResult> = {
description: 'Redis',
shouldStart: (ctx) => ctx.isQueueMode,
async start(network: StartedNetwork, projectName: string): Promise<RedisResult> {
const container = await new RedisContainer(TEST_CONTAINER_IMAGES.redis)
.withNetwork(network)
.withNetworkAliases(HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': HOSTNAME,
})
.withName(`${projectName}-${HOSTNAME}`)
.withReuse()
.start();
return {
container,
meta: {
host: HOSTNAME,
port: 6379,
},
};
},
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 {
// 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: host,
N8N_CACHE_REDIS_PORT: port,
};
},
};
@@ -0,0 +1,54 @@
import { cloudflared } from './cloudflared';
import { gitea, createGiteaHelper } from './gitea';
import { kafka, createKafkaHelper } from './kafka';
import { kent, createKentHelper } from './kent';
import { keycloak, createKeycloakHelper } from './keycloak';
import { loadBalancer } from './load-balancer';
import { localstack, createLocalStackHelper } from './localstack';
import { mailpit, createMailpitHelper } from './mailpit';
import { mysqlService } from './mysql';
import { ngrok } from './ngrok';
import { createObservabilityHelper } from './observability';
import { postgres } from './postgres';
import { proxy, createProxyHelper } from './proxy';
import { redis } from './redis';
import { taskRunner } from './task-runner';
import { tracing, createTracingHelper } from './tracing';
import type { Service, ServiceName, ServiceResult, HelperFactories } from './types';
import { vector } from './vector';
import { victoriaLogs } from './victoria-logs';
import { victoriaMetrics } from './victoria-metrics';
/** Service registry - must include all ServiceName entries */
export const services: Record<ServiceName, Service<ServiceResult>> = {
postgres,
redis,
mailpit,
gitea,
keycloak,
victoriaLogs,
victoriaMetrics,
vector,
tracing,
proxy,
taskRunner,
loadBalancer,
cloudflared,
ngrok,
kafka,
mysql: mysqlService,
localstack,
kent,
};
export const helperFactories: Partial<HelperFactories> = {
mailpit: createMailpitHelper,
gitea: createGiteaHelper,
keycloak: createKeycloakHelper,
observability: createObservabilityHelper,
tracing: createTracingHelper,
proxy: createProxyHelper,
kafka: createKafkaHelper,
localstack: createLocalStackHelper,
kent: createKentHelper,
};
@@ -0,0 +1,71 @@
import { GenericContainer, Wait } from 'testcontainers';
import { createSilentLogConsumer } from '../helpers/utils';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import { EXTERNAL_HOST, type Service, type ServiceResult } from './types';
export interface TaskRunnerConfig {
taskBrokerUri: string;
}
export interface TaskRunnerMeta {
taskBrokerUri: string;
}
export type TaskRunnerResult = ServiceResult<TaskRunnerMeta>;
export const taskRunner: Service<TaskRunnerResult> = {
description: 'Task Runner',
shouldStart: (ctx) => ctx.mains > 0 || ctx.workers > 0,
getOptions(ctx) {
if (ctx.external) {
return { taskBrokerUri: `http://${EXTERNAL_HOST}:5679` } as TaskRunnerConfig;
}
const { workers, mains, projectName } = ctx;
const taskBrokerHost =
workers > 0
? `${projectName}-n8n-worker-1`
: mains > 1
? `${projectName}-n8n-main-1`
: `${projectName}-n8n`;
return { taskBrokerUri: `http://${taskBrokerHost}:5679` } as TaskRunnerConfig;
},
async start(network, projectName, config?: unknown): Promise<TaskRunnerResult> {
const { taskBrokerUri } = config as TaskRunnerConfig;
const { consumer, throwWithLogs } = createSilentLogConsumer();
try {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.taskRunner)
.withNetwork(network)
.withNetworkAliases(`${projectName}-task-runner`)
.withExposedPorts(5680)
.withEnvironment({
N8N_RUNNERS_AUTH_TOKEN: 'test',
N8N_RUNNERS_LAUNCHER_LOG_LEVEL: 'debug',
N8N_RUNNERS_TASK_BROKER_URI: taskBrokerUri,
N8N_RUNNERS_MAX_CONCURRENCY: '5',
N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT: '0', // Disabled in tests to prevent cold-start delays
})
.withWaitStrategy(Wait.forListeningPorts())
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'task-runner',
})
.withName(`${projectName}-task-runner`)
.withReuse()
.withLogConsumer(consumer)
.start();
return {
container,
meta: {
taskBrokerUri,
},
};
} catch (error) {
return throwWithLogs(error);
}
},
};
@@ -0,0 +1,167 @@
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const JAEGER_OTLP_PORT = 4318;
const JAEGER_UI_PORT = 16686;
const N8N_TRACER_INGEST_PORT = 8889;
const N8N_TRACER_HEALTH_PORT = 8888;
const JAEGER_HOSTNAME = 'jaeger';
const N8N_TRACER_HOSTNAME = 'n8n-tracer';
export interface TracingConfig {
deploymentMode?: 'scaling';
}
export interface TracingMeta {
jaeger: {
uiUrl: string;
internalOtlpEndpoint: string;
};
tracer: {
internalIngestEndpoint: string;
ingestUrl: string;
};
}
export type TracingResult = ServiceResult<TracingMeta> & {
containers: StartedTestContainer[];
};
export interface TracerWebhookConfig {
url: string;
method: 'POST';
label: string;
subscribedEvents: string[];
}
export const tracing: Service<TracingResult> = {
description: 'Tracing stack (Jaeger + n8n-tracer)',
async start(
network: StartedNetwork,
projectName: string,
config?: unknown,
): Promise<TracingResult> {
const { deploymentMode = 'scaling' } = (config as TracingConfig) ?? {};
// Start Jaeger first (OTLP receiver)
const jaegerContainer = await new GenericContainer(TEST_CONTAINER_IMAGES.jaeger)
.withName(`${projectName}-jaeger`)
.withNetwork(network)
.withNetworkAliases(JAEGER_HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'jaeger',
})
.withExposedPorts(JAEGER_UI_PORT, JAEGER_OTLP_PORT)
.withEnvironment({
COLLECTOR_OTLP_ENABLED: 'true',
COLLECTOR_OTLP_HTTP_HOST_PORT: '0.0.0.0:4318',
})
.withWaitStrategy(
Wait.forHttp('/', JAEGER_UI_PORT).forStatusCode(200).withStartupTimeout(60000),
)
.withReuse()
.start();
const jaegerUiPort = jaegerContainer.getMappedPort(JAEGER_UI_PORT);
const internalOtlpEndpoint = `http://${JAEGER_HOSTNAME}:${JAEGER_OTLP_PORT}`;
// Start n8n-tracer pointing to Jaeger
const tracerContainer = await new GenericContainer(TEST_CONTAINER_IMAGES.n8nTracer)
.withName(`${projectName}-n8n-tracer`)
.withNetwork(network)
.withNetworkAliases(N8N_TRACER_HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'n8n-tracer',
})
.withExposedPorts(N8N_TRACER_INGEST_PORT, N8N_TRACER_HEALTH_PORT)
.withEnvironment({
N8N_DEPLOYMENT_MODE: deploymentMode,
OTEL_EXPORTER_OTLP_ENDPOINT: internalOtlpEndpoint,
HTTP_INGEST_PORT: String(N8N_TRACER_INGEST_PORT),
HEALTH_PORT: String(N8N_TRACER_HEALTH_PORT),
})
.withWaitStrategy(
Wait.forHttp('/health', N8N_TRACER_HEALTH_PORT)
.forStatusCode(200)
.withStartupTimeout(60000),
)
.withReuse()
.start();
const internalIngestEndpoint = `http://${N8N_TRACER_HOSTNAME}:${N8N_TRACER_INGEST_PORT}`;
return {
container: jaegerContainer, // Primary container
containers: [jaegerContainer, tracerContainer],
meta: {
jaeger: {
uiUrl: `http://localhost:${jaegerUiPort}`,
internalOtlpEndpoint,
},
tracer: {
internalIngestEndpoint,
ingestUrl: `${internalIngestEndpoint}/ingest`,
},
},
};
},
env(): Record<string, string> {
return {
N8N_LOG_OUTPUT: 'console',
};
},
};
export class TracingHelper {
private readonly meta: TracingMeta;
constructor(meta: TracingMeta) {
this.meta = meta;
}
get jaegerUiUrl(): string {
return this.meta.jaeger.uiUrl;
}
get internalOtlpEndpoint(): string {
return this.meta.jaeger.internalOtlpEndpoint;
}
get internalIngestEndpoint(): string {
return this.meta.tracer.internalIngestEndpoint;
}
get ingestUrl(): string {
return this.meta.tracer.ingestUrl;
}
getWebhookConfig(label = 'n8n-tracer'): TracerWebhookConfig {
return {
url: this.meta.tracer.ingestUrl,
method: 'POST',
label,
subscribedEvents: ['*'],
};
}
}
export function createTracingHelper(ctx: HelperContext): TracingHelper {
const result = ctx.serviceResults.tracing as TracingResult | undefined;
if (!result) {
throw new Error('Tracing service not found in context');
}
return new TracingHelper(result.meta);
}
declare module './types' {
interface ServiceHelpers {
tracing: TracingHelper;
}
}
@@ -0,0 +1,113 @@
import type { StartedTestContainer, StartedNetwork } from 'testcontainers';
/** Hostname that containers use to reach the host machine (Docker Desktop built-in) */
export const EXTERNAL_HOST = 'host.docker.internal';
export const SERVICE_NAMES = [
'postgres',
'redis',
'mailpit',
'gitea',
'keycloak',
'victoriaLogs',
'victoriaMetrics',
'vector',
'tracing',
'proxy',
'taskRunner',
'loadBalancer',
'cloudflared',
'kafka',
'ngrok',
'mysql',
'localstack',
'kent',
] as const;
export type ServiceName = (typeof SERVICE_NAMES)[number];
export interface FileToMount {
content: string;
target: string;
}
export interface ServiceMeta {
/**
* Files to mount into n8n containers. Use when n8n needs files that can't
* be passed via environment (e.g., NODE_EXTRA_CA_CERTS requires a file path).
* See keycloak.ts for usage example.
*/
n8nFilesToMount?: FileToMount[];
}
export interface ServiceResult<TMeta = unknown> {
container: StartedTestContainer;
meta: TMeta;
}
export interface StartContext {
config: StackConfig;
projectName: string;
mains: number;
workers: number;
isQueueMode: boolean;
usePostgres: boolean;
needsLoadBalancer: boolean;
/** When true, services should target host.testcontainers.internal instead of Docker-internal hostnames */
external: boolean;
environment: Record<string, string>;
serviceResults: Partial<Record<ServiceName, ServiceResult>>;
allocatedPorts: { main?: number; loadBalancer?: number };
baseUrl?: string;
}
export interface StackConfig {
mains?: number;
workers?: number;
postgres?: boolean;
env?: Record<string, string>;
projectName?: string;
resourceQuota?: { memory?: number; cpu?: number };
services?: readonly ServiceName[];
/** When true, services target host machine instead of Docker-internal n8n */
external?: boolean;
}
export interface Service<TResult extends ServiceResult = ServiceResult> {
/** @example 'Redis' */
readonly description: string;
/** @example ['victoriaLogs'] // vector depends on victoriaLogs */
readonly dependsOn?: readonly ServiceName[];
/** @example (ctx) => ctx.isQueueMode // redis auto-starts in queue mode */
shouldStart?(ctx: StartContext): boolean;
/** @example (ctx) => ({ taskBrokerUri: `http://${ctx.projectName}-n8n:5679` }) */
getOptions?(ctx: StartContext): unknown;
/** Starts container, returns connection details for env() */
start(
network: StartedNetwork,
projectName: string,
options?: unknown,
ctx?: StartContext,
): Promise<TResult>;
/** @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>;
}
export interface HelperContext {
containers: StartedTestContainer[];
findContainer(pattern: RegExp): StartedTestContainer | undefined;
serviceResults: Partial<Record<ServiceName, ServiceResult>>;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ServiceHelpers {}
export type HelperFactory<T> = (ctx: HelperContext) => T;
export type HelperFactories = {
[K in keyof ServiceHelpers]: HelperFactory<ServiceHelpers[K]>;
};
@@ -0,0 +1,99 @@
import type { StartedNetwork } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { Service, ServiceResult, StartContext } from './types';
import type { VictoriaLogsResult } from './victoria-logs';
const VICTORIA_LOGS_HOSTNAME = 'victoria-logs';
const VICTORIA_LOGS_HTTP_PORT = 9428;
function generateVectorConfig(projectName: string, victoriaLogsEndpoint: string): string {
return `
# Disable healthcheck to allow Vector to start while Docker socket becomes available
[healthchecks]
enabled = false
[sources.docker_logs]
type = "docker_logs"
include_labels = ["com.docker.compose.project=${projectName}"]
[transforms.format_for_victorialogs]
type = "remap"
inputs = ["docker_logs"]
source = '''
._msg = .message
._time = .timestamp
.project = "${projectName}"
.service = .label."com.docker.compose.service" || "unknown"
.container = .container_name || "unknown"
._stream = .stream || "unknown"
del(.message)
del(.timestamp)
del(.label)
del(.source_type)
del(.stream)
'''
[sinks.victoria_logs]
type = "http"
inputs = ["format_for_victorialogs"]
uri = "${victoriaLogsEndpoint}/insert/jsonline"
method = "post"
framing.method = "newline_delimited"
encoding.codec = "json"
`;
}
export type VectorResult = ServiceResult<Record<string, never>>;
export const vector: Service<VectorResult> = {
description: 'Vector log collector',
dependsOn: ['victoriaLogs'],
async start(
network: StartedNetwork,
projectName: string,
_config?: unknown,
ctx?: StartContext,
): Promise<VectorResult> {
// Get the VictoriaLogs internal endpoint from the already-started service
const victoriaLogsResult = ctx?.serviceResults.victoriaLogs as VictoriaLogsResult | undefined;
const logsInternalEndpoint =
victoriaLogsResult?.meta.internalEndpoint ??
`http://${VICTORIA_LOGS_HOSTNAME}:${VICTORIA_LOGS_HTTP_PORT}`;
const vectorConfig = generateVectorConfig(projectName, logsInternalEndpoint);
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.vector)
.withName(`${projectName}-vector`)
.withNetwork(network)
.withNetworkAliases('vector')
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'vector',
})
.withBindMounts([
{
source: '/var/run/docker.sock',
target: '/var/run/docker.sock',
mode: 'ro',
},
])
.withCopyContentToContainer([
{
content: vectorConfig,
target: '/etc/vector/vector.toml',
},
])
.withCommand(['--config', '/etc/vector/vector.toml'])
.withWaitStrategy(Wait.forLogMessage(/Vector has started/, 1).withStartupTimeout(60000))
.withReuse()
.start();
return {
container,
meta: {},
};
},
};
@@ -0,0 +1,156 @@
import type { StartedNetwork } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult } from './types';
const VICTORIA_LOGS_HTTP_PORT = 9428;
const VICTORIA_LOGS_SYSLOG_PORT = 514;
const VICTORIA_LOGS_HOSTNAME = 'victoria-logs';
const SYSLOG_FACILITY_LOCAL0 = 16; // RFC 5424
export interface VictoriaLogsMeta {
queryEndpoint: string;
internalEndpoint: string;
syslog: {
host: string;
port: number;
protocol: 'tcp' | 'udp';
facility: number;
appName: string;
};
}
export type VictoriaLogsResult = ServiceResult<VictoriaLogsMeta>;
export const victoriaLogs: Service<VictoriaLogsResult> = {
description: 'VictoriaLogs',
async start(network: StartedNetwork, projectName: string): Promise<VictoriaLogsResult> {
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.victoriaLogs)
.withName(`${projectName}-victoria-logs`)
.withNetwork(network)
.withNetworkAliases(VICTORIA_LOGS_HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'victoria-logs',
})
.withExposedPorts(VICTORIA_LOGS_HTTP_PORT, VICTORIA_LOGS_SYSLOG_PORT)
.withCommand([
'-storageDataPath=/victoria-logs-data',
'-retentionPeriod=1d',
`-syslog.listenAddr.tcp=:${VICTORIA_LOGS_SYSLOG_PORT}`,
])
.withWaitStrategy(
Wait.forHttp('/health', VICTORIA_LOGS_HTTP_PORT)
.forStatusCode(200)
.withStartupTimeout(60000),
)
.withReuse()
.start();
const httpPort = container.getMappedPort(VICTORIA_LOGS_HTTP_PORT);
return {
container,
meta: {
queryEndpoint: `http://localhost:${httpPort}`,
internalEndpoint: `http://${VICTORIA_LOGS_HOSTNAME}:${VICTORIA_LOGS_HTTP_PORT}`,
syslog: {
host: VICTORIA_LOGS_HOSTNAME,
port: VICTORIA_LOGS_SYSLOG_PORT,
protocol: 'tcp',
facility: SYSLOG_FACILITY_LOCAL0,
appName: 'n8n',
},
},
};
},
env(): Record<string, string> {
return {
N8N_LOG_OUTPUT: 'console',
};
},
};
export interface LogEntry {
_time: string;
_msg: string;
message: string;
[key: string]: string | undefined;
}
export interface LogQueryOptions {
limit?: number;
start?: string;
end?: string;
timeoutMs?: number;
intervalMs?: number;
}
export class LogsHelper {
constructor(private readonly endpoint: string) {}
async exportAll(options: LogQueryOptions = {}): Promise<string> {
const logs = await this.query('*', { limit: 10000, ...options });
return logs.map((log) => JSON.stringify(log)).join('\n');
}
async query(query: string, options: LogQueryOptions = {}): Promise<LogEntry[]> {
const params = new URLSearchParams({ query });
if (options.limit) params.set('limit', String(options.limit));
if (options.start) params.set('start', options.start);
if (options.end) params.set('end', options.end);
const response = await fetch(`${this.endpoint}/select/logsql/query?${params}`);
if (!response.ok) {
throw new Error(`VictoriaLogs query failed: ${response.status}`);
}
const text = await response.text();
if (!text.trim()) return [];
return text
.trim()
.split('\n')
.filter(Boolean)
.map((line) => {
try {
const entry = JSON.parse(line) as LogEntry;
entry.message = entry._msg;
return entry;
} catch {
throw new Error(`Failed to parse VictoriaLogs line: ${line}`);
}
});
}
async waitForLog(query: string, options: LogQueryOptions = {}): Promise<LogEntry | null> {
const { setTimeout: wait } = await import('node:timers/promises');
const deadline = Date.now() + (options.timeoutMs ?? 30000);
const interval = options.intervalMs ?? 1000;
while (Date.now() < deadline) {
const logs = await this.query(query, options);
if (logs.length > 0) return logs[0];
await wait(interval);
}
return null;
}
}
export function createLogsHelper(ctx: HelperContext): LogsHelper {
const result = ctx.serviceResults.victoriaLogs as VictoriaLogsResult | undefined;
if (!result) {
throw new Error('VictoriaLogs service not found in context');
}
return new LogsHelper(result.meta.queryEndpoint);
}
/**
* Escape special characters in LogsQL queries.
*/
export function escapeLogsQL(str: string): string {
return str.replace(/["\\]/g, '\\$&');
}
@@ -0,0 +1,223 @@
import type { StartedNetwork } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { HelperContext, Service, ServiceResult, StartContext } from './types';
const VICTORIA_METRICS_HTTP_PORT = 8428;
const VICTORIA_METRICS_HOSTNAME = 'victoria-metrics';
export interface ScrapeTarget {
job: string;
instance: string;
host: string;
port: number;
}
export interface VictoriaMetricsConfig {
scrapeTargets: ScrapeTarget[];
}
export interface VictoriaMetricsMeta {
queryEndpoint: string;
internalEndpoint: string;
}
export type VictoriaMetricsResult = ServiceResult<VictoriaMetricsMeta>;
function generateScrapeConfig(targets: ScrapeTarget[]): string {
const jobGroups = new Map<string, ScrapeTarget[]>();
for (const target of targets) {
const existing = jobGroups.get(target.job) ?? [];
existing.push(target);
jobGroups.set(target.job, existing);
}
const scrapeConfigs: string[] = [];
for (const [jobName, jobTargets] of jobGroups) {
const targetConfigs = jobTargets
.map(
(t) => ` - targets: ['${t.host}:${t.port}']
labels:
instance: '${t.instance}'`,
)
.join('\n');
scrapeConfigs.push(` - job_name: '${jobName}'
static_configs:
${targetConfigs}
metrics_path: '/metrics'
scrape_interval: '5s'`);
}
return `
global:
scrape_interval: 15s
scrape_configs:
${scrapeConfigs.join('\n')}
`;
}
export const victoriaMetrics: Service<VictoriaMetricsResult> = {
description: 'VictoriaMetrics',
getOptions(ctx: StartContext): VictoriaMetricsConfig {
const { mains, workers, projectName } = ctx;
const scrapeTargets: ScrapeTarget[] = [];
for (let i = 1; i <= mains; i++) {
const hostname = mains > 1 ? `${projectName}-n8n-main-${i}` : `${projectName}-n8n`;
scrapeTargets.push({
job: 'n8n-main',
instance: `n8n-main-${i}`,
host: hostname,
port: 5678,
});
}
for (let i = 1; i <= workers; i++) {
scrapeTargets.push({
job: 'n8n-worker',
instance: `n8n-worker-${i}`,
host: `${projectName}-n8n-worker-${i}`,
port: 5678,
});
}
return { scrapeTargets };
},
async start(
network: StartedNetwork,
projectName: string,
config?: unknown,
): Promise<VictoriaMetricsResult> {
const { scrapeTargets = [] } = (config as VictoriaMetricsConfig) ?? {};
const scrapeConfig = generateScrapeConfig(scrapeTargets);
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.victoriaMetrics)
.withName(`${projectName}-victoria-metrics`)
.withNetwork(network)
.withNetworkAliases(VICTORIA_METRICS_HOSTNAME)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'victoria-metrics',
})
.withExposedPorts(VICTORIA_METRICS_HTTP_PORT)
.withCommand([
'-storageDataPath=/victoria-metrics-data',
'-retentionPeriod=1d',
'-promscrape.config=/etc/prometheus/prometheus.yml',
])
.withCopyContentToContainer([
{
content: scrapeConfig,
target: '/etc/prometheus/prometheus.yml',
},
])
.withWaitStrategy(
Wait.forHttp('/health', VICTORIA_METRICS_HTTP_PORT)
.forStatusCode(200)
.withStartupTimeout(60000),
)
.withReuse()
.start();
const httpPort = container.getMappedPort(VICTORIA_METRICS_HTTP_PORT);
return {
container,
meta: {
queryEndpoint: `http://localhost:${httpPort}`,
internalEndpoint: `http://${VICTORIA_METRICS_HOSTNAME}:${VICTORIA_METRICS_HTTP_PORT}`,
},
};
},
env(): Record<string, string> {
return {
N8N_METRICS_ENABLED: 'true',
};
},
};
export interface MetricResult {
labels: Record<string, string>;
value: number;
}
export interface WaitForMetricOptions {
timeoutMs?: number;
intervalMs?: number;
predicate?: (values: MetricResult[]) => boolean;
}
export class MetricsHelper {
constructor(private readonly endpoint: string) {}
async exportAll(options: { start?: string; end?: string } = {}): Promise<string> {
const params = new URLSearchParams({
'match[]': '{__name__=~".+"}',
});
if (options.start) params.set('start', options.start);
if (options.end) params.set('end', options.end);
const response = await fetch(`${this.endpoint}/api/v1/export?${params}`);
if (!response.ok) {
throw new Error(`VictoriaMetrics export failed: ${response.status}`);
}
return await response.text();
}
async query(query: string): Promise<MetricResult[]> {
const response = await fetch(`${this.endpoint}/api/v1/query?${new URLSearchParams({ query })}`);
if (!response.ok) {
throw new Error(`VictoriaMetrics query failed: ${response.status}`);
}
const data = (await response.json()) as {
status: string;
data?: { result: Array<{ metric: Record<string, string>; value: [number, string] }> };
error?: string;
};
if (data.status !== 'success') {
throw new Error(`VictoriaMetrics error: ${data.error}`);
}
return (data.data?.result ?? []).map((r) => ({
labels: r.metric,
value: parseFloat(r.value[1]),
}));
}
async waitForMetric(
query: string,
options: WaitForMetricOptions = {},
): Promise<MetricResult | null> {
const { setTimeout: wait } = await import('node:timers/promises');
const deadline = Date.now() + (options.timeoutMs ?? 30000);
const interval = options.intervalMs ?? 1000;
const predicate = options.predicate ?? ((v) => v.length > 0);
while (Date.now() < deadline) {
try {
const values = await this.query(query);
if (predicate(values)) return values[0] ?? null;
} catch {
// Ignore transient errors during polling
}
await wait(interval);
}
return null;
}
}
export function createMetricsHelper(ctx: HelperContext): MetricsHelper {
const result = ctx.serviceResults.victoriaMetrics as VictoriaMetricsResult | undefined;
if (!result) {
throw new Error('VictoriaMetrics service not found in context');
}
return new MetricsHelper(result.meta.queryEndpoint);
}