mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
test: Add testcontainers infrastructure and MySQL integration tests (#24603)
This commit is contained in:
parent
36afab0ec0
commit
8474689a49
15
.github/workflows/test-db-postgres-mysql.yml
vendored
15
.github/workflows/test-db-postgres-mysql.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/test-e2e-reusable.yml
vendored
2
.github/workflows/test-e2e-reusable.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
25
packages/cli/jest.config.integration.testcontainers.js
Normal file
25
packages/cli/jest.config.integration.testcontainers.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
35
packages/cli/test/setup-testcontainers.js
Normal file
35
packages/cli/test/setup-testcontainers.js
Normal 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`,
|
||||
);
|
||||
};
|
||||
10
packages/cli/test/teardown-testcontainers.js
Normal file
10
packages/cli/test/teardown-testcontainers.js
Normal 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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
27
packages/nodes-base/vitest.integration.config.ts
Normal file
27
packages/nodes-base/vitest.integration.config.ts
Normal 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 } },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
30
packages/testing/containers/service-stack.ts
Normal file
30
packages/testing/containers/service-stack.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
60
packages/testing/containers/services/mysql.ts
Normal file
60
packages/testing/containers/services/mysql.ts
Normal 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 {};
|
||||
},
|
||||
};
|
||||
|
|
@ -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)`);
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const SERVICE_NAMES = [
|
|||
'cloudflared',
|
||||
'kafka',
|
||||
'ngrok',
|
||||
'mysql',
|
||||
] as const;
|
||||
|
||||
export type ServiceName = (typeof SERVICE_NAMES)[number];
|
||||
|
|
|
|||
|
|
@ -236,4 +236,4 @@ export class TelemetryRecorder {
|
|||
|
||||
export function createTelemetryRecorder(config: StackConfig): TelemetryRecorder {
|
||||
return new TelemetryRecorder(config);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user