test: Add testcontainers infrastructure and MySQL integration tests (#24603)

This commit is contained in:
Declan Carroll 2026-01-28 09:33:43 +00:00 committed by GitHub
parent 36afab0ec0
commit 8474689a49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 466 additions and 76 deletions

View File

@ -115,27 +115,22 @@ jobs:
postgres:
name: Postgres
needs: build
runs-on: blacksmith-2vcpu-ubuntu-2204
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 20
env:
DB_POSTGRESDB_PASSWORD: password
DB_POSTGRESDB_POOL_SIZE: 1 # Detect connection pooling deadlocks
TEST_IMAGE_POSTGRES: postgres:16
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
- name: Start Postgres
uses: isbang/compose-action@802a148945af6399a338c7906c267331b39a71af # v2.0.0
with:
compose-file: ./.github/docker-compose.yml
services: |
postgres
- name: Pre-pull Test Container Images
run: npx tsx packages/testing/containers/pull-test-images.ts || true
- name: Test Postgres
working-directory: packages/cli
run: pnpm test:postgres
run: pnpm test:postgres:tc
notify-on-failure:
name: Notify Slack on failure

View File

@ -80,7 +80,7 @@ env:
PLAYWRIGHT_WORKERS: ${{ inputs.workers != '' && inputs.workers || '2' }}
# Browser cache location - must match install-browsers script
PLAYWRIGHT_BROWSERS_PATH: packages/testing/playwright/.playwright-browsers
N8N_DOCKER_IMAGE: ${{ inputs.test-mode == 'docker-build' && 'n8nio/n8n:local' || inputs.docker-image }}
TEST_IMAGE_N8N: ${{ inputs.test-mode == 'docker-build' && 'n8nio/n8n:local' || inputs.docker-image }}
N8N_SKIP_LICENSES: 'true'
CURRENTS_CI_BUILD_ID: ${{ github.repository }}-${{ github.run_id }}-${{ github.run_attempt }}
CURRENTS_PROJECT_ID: ${{ inputs.currents-project-id }}

View File

@ -2,7 +2,7 @@ import { defineConfig, globalIgnores } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(
globalIgnores(['scripts/**/*.mjs', 'jest.config*.js']),
globalIgnores(['scripts/**/*.mjs', 'jest.config*.js', 'test/*-testcontainers.js']),
nodeConfig,
{
rules: {

View File

@ -0,0 +1,25 @@
/**
* Jest config for integration tests using testcontainers.
* This starts postgres automatically via testcontainers - no docker-compose needed.
*
* Usage: pnpm test:postgres:tc
*
* Note: TESTCONTAINERS_RYUK_DISABLED=true is set in the npm script because:
* 1. Ryuk requires privileged Docker access not available in all CI environments
* 2. Containers use withReuse() and are cleaned up in globalTeardown
* 3. If tests crash, containers may linger - run `docker ps` to check
*/
/** @type {import('jest').Config} */
module.exports = {
...require('./jest.config.integration.js'),
globalSetup: '<rootDir>/test/setup-testcontainers.js',
globalTeardown: '<rootDir>/test/teardown-testcontainers.js',
// Longer timeout for container startup
testTimeout: 30_000,
// Disable caching - testcontainers' signal-exit conflicts with Jest's
// transform cache (write-file-atomic). Performance impact is minimal
// since integration tests are I/O-bound, not transform-bound.
cache: false,
forceExit: true,
};

View File

@ -26,6 +26,7 @@
"test:dev": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --watch",
"test:sqlite": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js --no-coverage",
"test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage",
"test:postgres:tc": "TESTCONTAINERS_RYUK_DISABLED=true N8N_LOG_LEVEL=silent jest --config=jest.config.integration.testcontainers.js --no-coverage",
"test:mariadb": "echo true",
"test:mysql": "echo true",
"test:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest",
@ -57,6 +58,7 @@
],
"devDependencies": {
"@n8n/backend-test-utils": "workspace:*",
"n8n-containers": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@redocly/cli": "^1.28.5",
"@types/aws4": "^1.5.1",

View File

@ -0,0 +1,35 @@
/**
* Jest global setup - plain JS to bypass Jest's transform system.
* Uses createServiceStack from n8n-containers for unified service management.
*/
const { createServiceStack } = require('n8n-containers');
module.exports = async () => {
const stack = await createServiceStack({
services: ['postgres'],
projectName: 'n8n-integration-test',
});
const pgResult = stack.serviceResults.postgres;
if (!pgResult) {
throw new Error('Failed to start postgres container');
}
const container = pgResult.container;
process.env.DB_TYPE = 'postgresdb';
process.env.DB_POSTGRESDB_HOST = container.getHost();
process.env.DB_POSTGRESDB_PORT = String(container.getMappedPort(5432));
process.env.DB_POSTGRESDB_DATABASE = pgResult.meta.database;
process.env.DB_POSTGRESDB_USER = pgResult.meta.username;
process.env.DB_POSTGRESDB_PASSWORD = pgResult.meta.password;
process.env.DB_POSTGRESDB_SCHEMA = 'alt_schema';
process.env.DB_TABLE_PREFIX = 'test_';
process.env.DB_POSTGRESDB_POOL_SIZE = '1'; // Detect connection pooling deadlocks
globalThis.__TESTCONTAINERS_STACK__ = stack;
console.log(
`\n✓ Postgres ready at ${process.env.DB_POSTGRESDB_HOST}:${process.env.DB_POSTGRESDB_PORT}\n`,
);
};

View File

@ -0,0 +1,10 @@
/**
* Jest global teardown - plain JS to bypass Jest's transform system.
*/
module.exports = async () => {
const stack = globalThis.__TESTCONTAINERS_STACK__;
if (stack) {
await stack.stop();
console.log('\n✓ Testcontainers stack stopped\n');
}
};

View File

@ -4,6 +4,7 @@ process.env.TZ = 'UTC';
/** @type {import('jest').Config} */
module.exports = {
...require('../../jest.config'),
testPathIgnorePatterns: ['/dist/', '/node_modules/', '\\.integration\\.test\\.ts$'],
collectCoverageFrom: ['credentials/**/*.ts', 'nodes/**/*.ts', 'utils/**/*.ts'],
globalSetup: '<rootDir>/test/globalSetup.ts',
setupFilesAfterEnv: ['jest-expect-message', '<rootDir>/test/setup.ts'],

View File

@ -0,0 +1,129 @@
import { describe, test, expect, beforeAll, afterAll } from 'vitest';
import { createServiceStack, type N8NStack } from 'n8n-containers';
import { constructExecutionMetaData } from 'n8n-core';
import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow';
import { router } from '../../v2/actions/router';
import type { MysqlNodeCredentials } from '../../v2/helpers/interfaces';
let stack: N8NStack;
let credentials: MysqlNodeCredentials;
beforeAll(async () => {
stack = await createServiceStack({ services: ['mysql'] });
const meta = stack.serviceResults.mysql!.meta as {
externalHost: string;
externalPort: number;
database: string;
username: string;
password: string;
};
credentials = {
host: meta.externalHost,
port: meta.externalPort,
database: meta.database,
user: meta.username,
password: meta.password,
connectTimeout: 10000,
ssl: false,
sshTunnel: false,
};
});
afterAll(async () => {
await stack?.stop();
});
const node: INode = {
id: '1',
name: 'MySQL',
typeVersion: 2.5,
type: 'n8n-nodes-base.mySql',
position: [0, 0],
parameters: {},
};
function mockExecuteFunctions(
params: IDataObject,
creds: MysqlNodeCredentials,
continueOnFail = false,
): IExecuteFunctions {
return {
getNodeParameter: (name: string, _i: number, fallback?: unknown) => params[name] ?? fallback,
getNode: () => node,
getInputData: () => [{ json: {} }],
continueOnFail: () => continueOnFail,
getCredentials: async () => creds,
helpers: {
constructExecutionMetaData,
getSSHClient: () => {
throw new Error('No SSH');
},
},
} as unknown as IExecuteFunctions;
}
const badCreds: MysqlNodeCredentials = {
host: 'invalid.host',
port: 3306,
database: 'x',
user: 'x',
password: 'x',
connectTimeout: 1000,
ssl: false,
sshTunnel: false,
};
describe('MySQL Integration - NODE-4174', () => {
test('happy path: SELECT against real MySQL', async () => {
const params = {
resource: 'database',
operation: 'executeQuery',
query: 'SELECT 1 as result',
options: { queryBatching: 'single', nodeVersion: 2.5 },
};
const result = await router.call(mockExecuteFunctions(params, credentials));
expect(result[0][0].json).toEqual({ result: 1 });
});
test('bad path: query error returns error item with continueOnFail', async () => {
const params = {
resource: 'database',
operation: 'executeQuery',
query: 'SELECT * FROM table_that_does_not_exist',
options: { queryBatching: 'single', nodeVersion: 2.5 },
};
const result = await router.call(mockExecuteFunctions(params, credentials, true));
expect(result[0][0].json).toHaveProperty('message');
});
// NODE-4174: createPool() is outside try-catch, so connection errors bypass continueOnFail.
// Remove .fails when fixed.
test.fails('bug: connection error should return error item with continueOnFail', async () => {
const params = {
resource: 'database',
operation: 'executeQuery',
query: 'SELECT 1',
options: { queryBatching: 'single', nodeVersion: 2.5, connectTimeout: 1000 },
};
const result = await router.call(mockExecuteFunctions(params, badCreds, true));
expect(result[0][0].json).toHaveProperty('error');
});
test('connection error throws when continueOnFail is false', async () => {
const params = {
resource: 'database',
operation: 'executeQuery',
query: 'SELECT 1',
options: { queryBatching: 'single', nodeVersion: 2.5, connectTimeout: 1000 },
};
await expect(router.call(mockExecuteFunctions(params, badCreds, false))).rejects.toThrow();
});
});

View File

@ -15,7 +15,8 @@
"lint:fix": "eslint nodes credentials utils test --fix",
"watch": "tsc-watch -p tsconfig.build.cjs.json --onCompilationComplete \"pnpm copy-nodes-json && tsc-alias -p tsconfig.build.cjs.json\" --onSuccess \"pnpm n8n-generate-metadata\"",
"test": "jest",
"test:dev": "jest --watch"
"test:dev": "jest --watch",
"test:integration": "vitest run --config vitest.integration.config.ts"
},
"files": [
"dist"
@ -853,6 +854,9 @@
"@n8n/client-oauth2": "workspace:*",
"@n8n/eslint-plugin-community-nodes": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"n8n-containers": "workspace:*",
"vitest": "catalog:",
"@types/amqplib": "^0.10.1",
"@types/aws4": "^1.5.1",
"@types/basic-auth": "catalog:",

View File

@ -42,6 +42,7 @@
"../core/nodes-testing/**/*.ts",
"../../node_modules/jest-expect-message/types/index.d.ts"
],
"exclude": [],
"references": [
{ "path": "../@n8n/imap/tsconfig.build.json" },
{ "path": "../workflow/tsconfig.build.esm.json" },

View File

@ -0,0 +1,27 @@
import { resolve } from 'path';
import { mergeConfig } from 'vitest/config';
import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decorators';
export default mergeConfig(
createVitestConfigWithDecorators({
include: ['**/*.integration.test.ts'],
testTimeout: 120_000,
hookTimeout: 120_000,
}),
{
resolve: {
alias: {
'@credentials': resolve(__dirname, 'credentials'),
'@test': resolve(__dirname, 'test'),
'@utils': resolve(__dirname, 'utils'),
'@nodes-testing': resolve(__dirname, '../core/nodes-testing'),
},
},
test: {
fileParallelism: false,
sequence: { concurrent: false },
pool: 'forks',
poolOptions: { forks: { singleFork: true } },
},
},
);

View File

@ -372,7 +372,7 @@ The `observability` capability shortcut handles this automatically: `test.use({
| `--name <name>` | Custom project name for parallel runs |
| `--env KEY=VALUE` | Set environment variables |
| `--observability` | Enable metrics/logs stack |
| `--tracing` | Enable Jaeger tracing |
| `--tracing` | Enable tracing stack (Jaeger) |
| `--tunnel` | Enable Cloudflare Tunnel for public webhook URLs |
| `--oidc` | Enable Keycloak |
| `--source-control` | Enable Gitea |
@ -426,5 +426,5 @@ pnpm stack:clean:all
- **Container Reuse**: Set `TESTCONTAINERS_REUSE_ENABLE=true` for faster restarts
- **Parallel Testing**: Use `--name` to run multiple stacks without conflicts
- **Custom Image**: Set `N8N_DOCKER_IMAGE=n8nio/n8n:dev` to use a different image
- **Custom Image**: Set `TEST_IMAGE_N8N=n8nio/n8n:dev` to use a different image
- **Multi-Main**: Requires queue mode and license key in `N8N_LICENSE_ACTIVATION_KEY`

View File

@ -1,21 +1,21 @@
import { getDockerImageFromEnv } from './docker-image';
import { TEST_CONTAINER_IMAGES } from './test-containers';
// Custom error class for when the Docker image is not found locally/remotely
// This can happen when using the "n8nio/n8n:local" image, which is not available on Docker Hub
// This image is available after running `pnpm build:docker` at the root of the repository
export class DockerImageNotFoundError extends Error {
constructor(containerName: string, originalError?: Error) {
const dockerImage = getDockerImageFromEnv();
const dockerImage = TEST_CONTAINER_IMAGES.n8n;
const message = `Failed to start container ${containerName}: Docker image '${dockerImage}' not found locally!
This is likely because the image is not available locally.
To fix this, you can either:
1. Build the image by running: pnpm build:docker at the root
2. Use a different image by setting: N8N_DOCKER_IMAGE=<image-tag>
2. Use a different image by setting: TEST_IMAGE_N8N=<image-tag>
Example with different image:
N8N_DOCKER_IMAGE=n8nio/n8n:latest npm run stack`;
TEST_IMAGE_N8N=n8nio/n8n:latest npm run stack`;
super(message);
this.name = 'DockerImageNotFoundError';

View File

@ -1,30 +0,0 @@
/**
* Get the Docker image to use for the n8n container
*/
export function getDockerImageFromEnv(defaultImage = 'n8nio/n8n:local') {
const configuredImage = process.env.N8N_DOCKER_IMAGE;
if (!configuredImage) {
return defaultImage;
}
const hasImageOrg = configuredImage.includes('/');
const hasImageTag = configuredImage.includes(':');
// Full image reference with org and tag (e.g., "n8nio/n8n:beta")
if (hasImageOrg && hasImageTag) {
return configuredImage;
}
// Image with org but no tag (e.g., "n8nio/n8n")
if (hasImageOrg) {
return configuredImage;
}
// Image with tag provided (e.g., "n8n:beta")
if (hasImageTag) {
return `n8nio/${configuredImage}`;
}
// Only tag name (e.g., "beta", "1.0.0")
return `n8nio/n8n:${configuredImage}`;
}

View File

@ -9,6 +9,9 @@
export { createN8NStack } from './stack';
export type { N8NConfig, N8NStack } from './stack';
// Service-only stack (no n8n containers) - for integration tests
export { createServiceStack } from './service-stack';
export type { StackTelemetryRecord } from './telemetry';
// Performance plans (CLI-only)
@ -17,3 +20,4 @@ export * from './performance-plans';
// Types used externally by tests
export { type LogEntry, type MetricsHelper } from './services/observability';
export { type GiteaHelper } from './services/gitea';
export { KafkaHelper } from './services/kafka';

View File

@ -1,7 +1,6 @@
#!/usr/bin/env tsx
import { parseArgs } from 'node:util';
import { getDockerImageFromEnv } from './docker-image';
import { DockerImageNotFoundError } from './docker-image-not-found-error';
import { BASE_PERFORMANCE_PLANS, isValidPerformancePlan } from './performance-plans';
import type { CloudflaredResult } from './services/cloudflared';
@ -14,6 +13,7 @@ import type { VictoriaLogsResult } from './services/victoria-logs';
import type { VictoriaMetricsResult } from './services/victoria-metrics';
import type { N8NConfig, N8NStack } from './stack';
import { createN8NStack } from './stack';
import { TEST_CONTAINER_IMAGES } from './test-containers';
// ANSI colors for terminal output
const colors = {
@ -70,7 +70,7 @@ ${Object.entries(BASE_PERFORMANCE_PLANS)
.join('\n')}
${colors.yellow}Environment Variables:${colors.reset}
N8N_DOCKER_IMAGE=<image> Use a custom Docker image (default: n8nio/n8n:local)
TEST_IMAGE_N8N=<image> Use a custom Docker image (default: n8nio/n8n:local)
${colors.yellow}Examples:${colors.reset}
${colors.bright}# Simple SQLite instance${colors.reset}
@ -323,7 +323,7 @@ async function main() {
}
function displayConfig(config: N8NConfig) {
const dockerImage = getDockerImageFromEnv();
const dockerImage = TEST_CONTAINER_IMAGES.n8n;
const mains = config.mains ?? 1;
const workers = config.workers ?? 0;
const isQueueMode = mains > 1 || workers > 0;

View File

@ -26,6 +26,7 @@
"license": "ISC",
"devDependencies": {
"@testcontainers/kafka": "^11.11.0",
"@testcontainers/mysql": "^11.11.0",
"@testcontainers/postgresql": "^11.0.3",
"@testcontainers/redis": "^11.0.3",
"get-port": "^7.1.0",

View File

@ -0,0 +1,30 @@
import type { ServiceName } from './services/types';
import { createN8NStack, type N8NStack } from './stack';
export interface ServiceStackOptions {
services: ServiceName[];
projectName?: string;
}
/**
* Creates a stack with only services (no n8n containers).
* Useful for integration tests that need databases/services but not full n8n.
*
* @example
* const stack = await createServiceStack({ services: ['postgres'] });
* const pgContainer = stack.serviceResults.postgres?.container;
* const host = pgContainer.getHost();
* const port = pgContainer.getMappedPort(5432);
* await stack.stop();
*/
export async function createServiceStack(options: ServiceStackOptions): Promise<N8NStack> {
const { services, projectName } = options;
return await createN8NStack({
mains: 0,
workers: 0,
postgres: services.includes('postgres'),
services,
projectName,
});
}

View File

@ -0,0 +1,60 @@
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(): Record<string, string> {
return {};
},
};

View File

@ -1,14 +1,13 @@
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
import { getDockerImageFromEnv } from '../docker-image';
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 = getDockerImageFromEnv(TEST_CONTAINER_IMAGES.n8n);
const N8N_IMAGE = TEST_CONTAINER_IMAGES.n8n;
const BASE_ENV: Record<string, string> = {
N8N_LOG_LEVEL: 'debug',
@ -210,6 +209,12 @@ export async function createN8NInstances(
})),
];
// 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)`);

View File

@ -4,6 +4,7 @@ import { kafka, createKafkaHelper } from './kafka';
import { keycloak, createKeycloakHelper } from './keycloak';
import { loadBalancer } from './load-balancer';
import { mailpit, createMailpitHelper } from './mailpit';
import { mysqlService } from './mysql';
import { ngrok } from './ngrok';
import { createObservabilityHelper } from './observability';
import { postgres } from './postgres';
@ -33,6 +34,7 @@ export const services: Record<ServiceName, Service<ServiceResult>> = {
cloudflared,
ngrok,
kafka,
mysql: mysqlService,
};
export const helperFactories: Partial<HelperFactories> = {

View File

@ -16,7 +16,7 @@ export type TaskRunnerResult = ServiceResult<TaskRunnerMeta>;
export const taskRunner: Service<TaskRunnerResult> = {
description: 'Task Runner',
shouldStart: () => true,
shouldStart: (ctx) => ctx.mains > 0 || ctx.workers > 0,
getOptions(ctx) {
const { workers, mains, projectName } = ctx;

View File

@ -16,6 +16,7 @@ export const SERVICE_NAMES = [
'cloudflared',
'kafka',
'ngrok',
'mysql',
] as const;
export type ServiceName = (typeof SERVICE_NAMES)[number];

View File

@ -236,4 +236,4 @@ export class TelemetryRecorder {
export function createTelemetryRecorder(config: StackConfig): TelemetryRecorder {
return new TelemetryRecorder(config);
}
}

View File

@ -1,41 +1,105 @@
/**
* Single source of truth for all test container images.
*
* All images can be overridden via environment variables:
* TEST_IMAGE_<KEY> where KEY is the SCREAMING_SNAKE_CASE version of the image key
* e.g., TEST_IMAGE_POSTGRES=postgres:16 overrides the postgres image
* e.g., TEST_IMAGE_N8N=n8nio/n8n:latest overrides the n8n image
* e.g., TEST_IMAGE_TASK_RUNNER=n8nio/runners:latest overrides the task runner image
*
* For n8n image, shorthand syntax is supported:
* TEST_IMAGE_N8N=stable n8nio/n8n:stable
* TEST_IMAGE_N8N=n8n:stable n8nio/n8n:stable
* TEST_IMAGE_N8N=n8nio/n8n:stable n8nio/n8n:stable
*
* N8N_DOCKER_IMAGE is also supported for backwards compatibility.
*/
// Use N8N_DOCKER_IMAGE env var if set, otherwise default to 'n8nio/n8n:local'
const n8nImage = process.env.N8N_DOCKER_IMAGE ?? 'n8nio/n8n:local';
// Derive the task runner image from the n8n image for consistency
// e.g., 'n8nio/n8n:local' -> 'n8nio/runners:local'
// e.g., 'ghcr.io/n8n-io/n8n:pr-123' -> 'ghcr.io/n8n-io/runners:pr-123'
function getTaskRunnerImage(): string {
// Allow explicit override via env var
if (process.env.N8N_RUNNERS_IMAGE) {
return process.env.N8N_RUNNERS_IMAGE;
}
return n8nImage.replace(/\/n8n:/, '/runners:');
}
export const TEST_CONTAINER_IMAGES = {
/** Default images - override via TEST_IMAGE_<KEY> env vars */
const DEFAULT_IMAGES = {
postgres: 'postgres:18-alpine',
redis: 'redis:alpine',
caddy: 'caddy:alpine',
n8n: n8nImage,
taskRunner: getTaskRunnerImage(),
n8n: 'n8nio/n8n:local',
taskRunner: 'n8nio/runners:local',
mailpit: 'axllent/mailpit:latest',
mockserver: 'mockserver/mockserver:5.15.0',
gitea: 'gitea/gitea:1.25.1',
keycloak: 'keycloak/keycloak:26.4',
// VictoriaObs stack for test observability
victoriaLogs: 'victoriametrics/victoria-logs:v1.21.0-victorialogs',
victoriaMetrics: 'victoriametrics/victoria-metrics:v1.115.0',
// Log collector for container logs
vector: 'timberio/vector:0.52.0-alpine',
// Tracing stack for workflow execution visualization
n8nTracer: 'ghcr.io/ivov/n8n-tracer:0.1.0',
jaeger: 'jaegertracing/all-in-one:1.76.0',
cloudflared: 'cloudflare/cloudflared:2025.1.1',
ngrok: 'ngrok/ngrok:alpine',
// Kafka for message queue testing
kafka: 'confluentinc/cp-kafka:8.0.3',
mysql: 'mysql:9.6.0',
} as const;
/**
* Convert camelCase to SCREAMING_SNAKE_CASE for env var names
* e.g., victoriaLogs -> VICTORIA_LOGS, taskRunner -> TASK_RUNNER
*/
function toEnvVarName(key: string): string {
return key.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase();
}
/**
* Normalize n8n image shorthand to full image reference.
* Supports: "stable", "n8n:stable", "n8nio/n8n:stable"
*/
function normalizeN8nImage(image: string): string {
const hasOrg = image.includes('/');
const hasTag = image.includes(':');
if (hasOrg) return image; // "n8nio/n8n:stable" → as-is
if (hasTag) return `n8nio/${image}`; // "n8n:stable" → "n8nio/n8n:stable"
return `n8nio/n8n:${image}`; // "stable" → "n8nio/n8n:stable"
}
/**
* Get image with env var override support.
* Checks TEST_IMAGE_<KEY> env var, falls back to default.
* For n8n, also supports N8N_DOCKER_IMAGE (backwards compat) and shorthand syntax.
*/
function getImage<K extends keyof typeof DEFAULT_IMAGES>(key: K): string {
const envVar = `TEST_IMAGE_${toEnvVarName(key)}`;
let value = process.env[envVar];
// Backwards compat for n8n image
if (key === 'n8n' && !value) {
value = process.env.N8N_DOCKER_IMAGE;
}
value = value ?? DEFAULT_IMAGES[key];
// Shorthand normalization for n8n image
if (key === 'n8n') {
return normalizeN8nImage(value);
}
return value;
}
export const TEST_CONTAINER_IMAGES = {
postgres: getImage('postgres'),
redis: getImage('redis'),
caddy: getImage('caddy'),
n8n: getImage('n8n'),
taskRunner: getImage('taskRunner'),
mailpit: getImage('mailpit'),
mockserver: getImage('mockserver'),
gitea: getImage('gitea'),
keycloak: getImage('keycloak'),
victoriaLogs: getImage('victoriaLogs'),
victoriaMetrics: getImage('victoriaMetrics'),
vector: getImage('vector'),
n8nTracer: getImage('n8nTracer'),
jaeger: getImage('jaeger'),
cloudflared: getImage('cloudflared'),
kafka: getImage('kafka'),
mysql: getImage('mysql'),
ngrok: getImage('ngrok'),
} as const;

View File

@ -2056,6 +2056,9 @@ importers:
mjml:
specifier: ^4.15.3
version: 4.15.3(encoding@0.1.13)
n8n-containers:
specifier: workspace:*
version: link:../testing/containers
openapi-types:
specifier: ^12.1.3
version: 12.1.3
@ -3387,6 +3390,9 @@ importers:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../@n8n/typescript-config
'@n8n/vitest-config':
specifier: workspace:*
version: link:../@n8n/vitest-config
'@types/amqplib':
specifier: ^0.10.1
version: 0.10.1
@ -3462,15 +3468,24 @@ importers:
eslint-plugin-n8n-nodes-base:
specifier: ^1.16.3
version: 1.16.3(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.2)
n8n-containers:
specifier: workspace:*
version: link:../testing/containers
n8n-core:
specifier: workspace:*
version: link:../core
vitest:
specifier: 'catalog:'
version: 3.1.3(@types/debug@4.1.12)(@types/node@20.19.21)(jiti@2.6.1)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(sass@1.89.2)(terser@5.16.1)(tsx@4.19.3)
packages/testing/containers:
devDependencies:
'@testcontainers/kafka':
specifier: ^11.11.0
version: 11.11.0
'@testcontainers/mysql':
specifier: ^11.11.0
version: 11.11.0
'@testcontainers/postgresql':
specifier: ^11.0.3
version: 11.0.3
@ -8388,6 +8403,9 @@ packages:
'@testcontainers/kafka@11.11.0':
resolution: {integrity: sha512-DGGoQL0wELifn1GHrfe4tW7ejYDIXNFHisx6wlgFzQ1xqaN8ppYlO658Fgg8B9+rcl0wY2rZF0oF0QFADKgqEg==}
'@testcontainers/mysql@11.11.0':
resolution: {integrity: sha512-2EfFhUDEvEdwBwez+F/NhqP+h2rFzLzHYbRX0N/9/Lgdlq8TbsYWZ9SaWL9V0f1FWX89XnyZrT3i/j7m8MIESg==}
'@testcontainers/postgresql@11.0.3':
resolution: {integrity: sha512-nMq0lBLguZecp9JDV4J8nlPWwxOQ4XiXUIGkV5Lvj1CMdVqbqQJe4eVrNHVC3rIM/w8nadNp5ecEoxnwkhG33w==}
@ -25300,6 +25318,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@testcontainers/mysql@11.11.0':
dependencies:
testcontainers: 11.11.0
transitivePeerDependencies:
- supports-color
'@testcontainers/postgresql@11.0.3':
dependencies:
testcontainers: 11.11.0