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
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:
@@ -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(/&/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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user