mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
test: Add task runner to test containers (#19254)
This commit is contained in:
@@ -37,6 +37,7 @@ ${colors.yellow}Usage:${colors.reset}
|
|||||||
${colors.yellow}Options:${colors.reset}
|
${colors.yellow}Options:${colors.reset}
|
||||||
--postgres Use PostgreSQL instead of SQLite
|
--postgres Use PostgreSQL instead of SQLite
|
||||||
--queue Enable queue mode (requires PostgreSQL)
|
--queue Enable queue mode (requires PostgreSQL)
|
||||||
|
--task-runner Enable external task runner container
|
||||||
--mains <n> Number of main instances (default: 1)
|
--mains <n> Number of main instances (default: 1)
|
||||||
--workers <n> Number of worker instances (default: 1)
|
--workers <n> Number of worker instances (default: 1)
|
||||||
--name <name> Project name for parallel runs
|
--name <name> Project name for parallel runs
|
||||||
@@ -65,6 +66,9 @@ ${colors.yellow}Examples:${colors.reset}
|
|||||||
${colors.bright}# Queue mode (automatically uses PostgreSQL)${colors.reset}
|
${colors.bright}# Queue mode (automatically uses PostgreSQL)${colors.reset}
|
||||||
npm run stack --queue
|
npm run stack --queue
|
||||||
|
|
||||||
|
${colors.bright}# With external task runner${colors.reset}
|
||||||
|
npm run stack --postgres --task-runner
|
||||||
|
|
||||||
${colors.bright}# Custom scaling${colors.reset}
|
${colors.bright}# Custom scaling${colors.reset}
|
||||||
npm run stack --queue --mains 3 --workers 5
|
npm run stack --queue --mains 3 --workers 5
|
||||||
|
|
||||||
@@ -96,6 +100,7 @@ async function main() {
|
|||||||
help: { type: 'boolean', short: 'h' },
|
help: { type: 'boolean', short: 'h' },
|
||||||
postgres: { type: 'boolean' },
|
postgres: { type: 'boolean' },
|
||||||
queue: { type: 'boolean' },
|
queue: { type: 'boolean' },
|
||||||
|
'task-runner': { type: 'boolean' },
|
||||||
mains: { type: 'string' },
|
mains: { type: 'string' },
|
||||||
workers: { type: 'string' },
|
workers: { type: 'string' },
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
@@ -114,6 +119,7 @@ async function main() {
|
|||||||
// Build configuration
|
// Build configuration
|
||||||
const config: N8NConfig = {
|
const config: N8NConfig = {
|
||||||
postgres: values.postgres ?? false,
|
postgres: values.postgres ?? false,
|
||||||
|
taskRunner: values['task-runner'] ?? false,
|
||||||
projectName: values.name ?? `n8n-stack-${Math.random().toString(36).substring(7)}`,
|
projectName: values.name ?? `n8n-stack-${Math.random().toString(36).substring(7)}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,6 +231,16 @@ function displayConfig(config: N8NConfig) {
|
|||||||
log.info('Queue mode: disabled');
|
log.info('Queue mode: disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display task runner status
|
||||||
|
if (config.taskRunner) {
|
||||||
|
log.info('Task runner: enabled (external container)');
|
||||||
|
if (!usePostgres) {
|
||||||
|
log.warn('Task runner recommended with PostgreSQL for better performance');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info('Task runner: disabled');
|
||||||
|
}
|
||||||
|
|
||||||
if (config.resourceQuota) {
|
if (config.resourceQuota) {
|
||||||
log.info(
|
log.info(
|
||||||
`Resource limits: ${config.resourceQuota.memory}GB RAM, ${config.resourceQuota.cpu} CPU cores`,
|
`Resource limits: ${config.resourceQuota.memory}GB RAM, ${config.resourceQuota.cpu} CPU cores`,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* - Single instances (SQLite or PostgreSQL)
|
* - Single instances (SQLite or PostgreSQL)
|
||||||
* - Queue mode with Redis
|
* - Queue mode with Redis
|
||||||
* - Multi-main instances with load balancing
|
* - Multi-main instances with load balancing
|
||||||
|
* - Task runner containers for external code execution
|
||||||
* - Parallel execution (multiple stacks running simultaneously)
|
* - Parallel execution (multiple stacks running simultaneously)
|
||||||
*
|
*
|
||||||
* Key features for parallel execution:
|
* Key features for parallel execution:
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
setupCaddyLoadBalancer,
|
setupCaddyLoadBalancer,
|
||||||
pollContainerHttpEndpoint,
|
pollContainerHttpEndpoint,
|
||||||
setupProxyServer,
|
setupProxyServer,
|
||||||
|
setupTaskRunner,
|
||||||
} from './n8n-test-container-dependencies';
|
} from './n8n-test-container-dependencies';
|
||||||
import { createSilentLogConsumer } from './n8n-test-container-utils';
|
import { createSilentLogConsumer } from './n8n-test-container-utils';
|
||||||
|
|
||||||
@@ -45,7 +47,6 @@ const BASE_ENV: Record<string, string> = {
|
|||||||
QUEUE_HEALTH_CHECK_ACTIVE: 'true',
|
QUEUE_HEALTH_CHECK_ACTIVE: 'true',
|
||||||
N8N_DIAGNOSTICS_ENABLED: 'false',
|
N8N_DIAGNOSTICS_ENABLED: 'false',
|
||||||
N8N_METRICS: 'true',
|
N8N_METRICS: 'true',
|
||||||
N8N_RUNNERS_ENABLED: 'true',
|
|
||||||
NODE_ENV: 'development', // If this is set to test, the n8n container will not start, insights module is not found??
|
NODE_ENV: 'development', // If this is set to test, the n8n container will not start, insights module is not found??
|
||||||
N8N_LICENSE_TENANT_ID: process.env.N8N_LICENSE_TENANT_ID ?? '1001',
|
N8N_LICENSE_TENANT_ID: process.env.N8N_LICENSE_TENANT_ID ?? '1001',
|
||||||
N8N_LICENSE_ACTIVATION_KEY: process.env.N8N_LICENSE_ACTIVATION_KEY ?? '',
|
N8N_LICENSE_ACTIVATION_KEY: process.env.N8N_LICENSE_ACTIVATION_KEY ?? '',
|
||||||
@@ -81,6 +82,7 @@ export interface N8NConfig {
|
|||||||
cpu?: number; // in cores
|
cpu?: number; // in cores
|
||||||
};
|
};
|
||||||
proxyServerEnabled?: boolean;
|
proxyServerEnabled?: boolean;
|
||||||
|
taskRunner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface N8NStack {
|
export interface N8NStack {
|
||||||
@@ -119,15 +121,18 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
|
|||||||
proxyServerEnabled = false,
|
proxyServerEnabled = false,
|
||||||
projectName,
|
projectName,
|
||||||
resourceQuota,
|
resourceQuota,
|
||||||
|
taskRunner = false,
|
||||||
} = config;
|
} = config;
|
||||||
const queueConfig = normalizeQueueConfig(queueMode);
|
const queueConfig = normalizeQueueConfig(queueMode);
|
||||||
|
const taskRunnerEnabled = !!taskRunner;
|
||||||
const usePostgres = postgres || !!queueConfig;
|
const usePostgres = postgres || !!queueConfig;
|
||||||
const uniqueProjectName = projectName ?? `n8n-stack-${Math.random().toString(36).substring(7)}`;
|
const uniqueProjectName = projectName ?? `n8n-stack-${Math.random().toString(36).substring(7)}`;
|
||||||
const containers: StartedTestContainer[] = [];
|
const containers: StartedTestContainer[] = [];
|
||||||
|
|
||||||
const mainCount = queueConfig?.mains ?? 1;
|
const mainCount = queueConfig?.mains ?? 1;
|
||||||
const needsLoadBalancer = mainCount > 1;
|
const needsLoadBalancer = mainCount > 1;
|
||||||
const needsNetwork = usePostgres || !!queueConfig || needsLoadBalancer || proxyServerEnabled;
|
const needsNetwork =
|
||||||
|
usePostgres || !!queueConfig || needsLoadBalancer || proxyServerEnabled || taskRunnerEnabled;
|
||||||
|
|
||||||
let network: StartedNetwork | undefined;
|
let network: StartedNetwork | undefined;
|
||||||
if (needsNetwork) {
|
if (needsNetwork) {
|
||||||
@@ -217,6 +222,16 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (taskRunnerEnabled) {
|
||||||
|
environment = {
|
||||||
|
...environment,
|
||||||
|
N8N_RUNNERS_ENABLED: 'true',
|
||||||
|
N8N_RUNNERS_MODE: 'external',
|
||||||
|
N8N_RUNNERS_AUTH_TOKEN: 'test',
|
||||||
|
N8N_RUNNERS_BROKER_LISTEN_ADDRESS: '0.0.0.0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
|
|
||||||
if (needsLoadBalancer) {
|
if (needsLoadBalancer) {
|
||||||
@@ -269,6 +284,21 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
|
|||||||
containers.push(...instances);
|
containers.push(...instances);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (taskRunnerEnabled && network) {
|
||||||
|
// Connect to first available broker (main or worker)
|
||||||
|
// In queue mode, workers also run task brokers
|
||||||
|
const taskBrokerUri = queueConfig?.workers
|
||||||
|
? `http://${uniqueProjectName}-n8n-worker-1:5679` // Prefer worker broker in queue mode
|
||||||
|
: `http://${uniqueProjectName}-n8n-main-1:5679`; // Use main broker otherwise
|
||||||
|
|
||||||
|
const taskRunnerContainer = await setupTaskRunner({
|
||||||
|
projectName: uniqueProjectName,
|
||||||
|
network,
|
||||||
|
taskBrokerUri,
|
||||||
|
});
|
||||||
|
containers.push(taskRunnerContainer);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
@@ -414,6 +444,7 @@ async function createN8NContainer({
|
|||||||
directPort,
|
directPort,
|
||||||
resourceQuota,
|
resourceQuota,
|
||||||
}: CreateContainerOptions): Promise<StartedTestContainer> {
|
}: CreateContainerOptions): Promise<StartedTestContainer> {
|
||||||
|
const taskRunnerEnabled = environment.N8N_RUNNERS_ENABLED === 'true';
|
||||||
const { consumer, throwWithLogs } = createSilentLogConsumer();
|
const { consumer, throwWithLogs } = createSilentLogConsumer();
|
||||||
|
|
||||||
let container = new GenericContainer(N8N_IMAGE);
|
let container = new GenericContainer(N8N_IMAGE);
|
||||||
@@ -445,13 +476,23 @@ async function createN8NContainer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isWorker) {
|
if (isWorker) {
|
||||||
container = container.withCommand(['worker']).withWaitStrategy(N8N_WORKER_WAIT_STRATEGY);
|
// Workers expose task broker port if task runners are enabled
|
||||||
|
const workerPorts = taskRunnerEnabled ? [5678, 5679] : [5678];
|
||||||
|
container = container
|
||||||
|
.withCommand(['worker'])
|
||||||
|
.withExposedPorts(...workerPorts)
|
||||||
|
.withWaitStrategy(N8N_WORKER_WAIT_STRATEGY);
|
||||||
} else {
|
} else {
|
||||||
container = container.withExposedPorts(5678).withWaitStrategy(N8N_MAIN_WAIT_STRATEGY);
|
// Mains always expose both ports (5678 for web, 5679 for task broker when enabled)
|
||||||
|
const mainPorts = taskRunnerEnabled ? [5678, 5679] : [5678];
|
||||||
|
container = container.withExposedPorts(...mainPorts).withWaitStrategy(N8N_MAIN_WAIT_STRATEGY);
|
||||||
|
|
||||||
if (directPort) {
|
if (directPort) {
|
||||||
|
const portMappings = taskRunnerEnabled
|
||||||
|
? [{ container: 5678, host: directPort }, 5679]
|
||||||
|
: [{ container: 5678, host: directPort }];
|
||||||
container = container
|
container = container
|
||||||
.withExposedPorts({ container: 5678, host: directPort })
|
.withExposedPorts(...portMappings)
|
||||||
.withWaitStrategy(N8N_MAIN_WAIT_STRATEGY);
|
.withWaitStrategy(N8N_MAIN_WAIT_STRATEGY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,5 +351,44 @@ export async function setupProxyServer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TASK_RUNNER_IMAGE = 'n8nio/runners:nightly';
|
||||||
|
|
||||||
|
export async function setupTaskRunner({
|
||||||
|
projectName,
|
||||||
|
network,
|
||||||
|
taskBrokerUri,
|
||||||
|
}: {
|
||||||
|
projectName: string;
|
||||||
|
network: StartedNetwork;
|
||||||
|
taskBrokerUri: string;
|
||||||
|
}): Promise<StartedTestContainer> {
|
||||||
|
const { consumer, throwWithLogs } = createSilentLogConsumer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await new GenericContainer(TASK_RUNNER_IMAGE)
|
||||||
|
.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: '15',
|
||||||
|
})
|
||||||
|
.withWaitStrategy(Wait.forListeningPorts())
|
||||||
|
.withLabels({
|
||||||
|
'com.docker.compose.project': projectName,
|
||||||
|
'com.docker.compose.service': 'task-runner',
|
||||||
|
})
|
||||||
|
.withName(`${projectName}-task-runner`)
|
||||||
|
.withReuse()
|
||||||
|
.withLogConsumer(consumer)
|
||||||
|
.start();
|
||||||
|
} catch (error) {
|
||||||
|
return throwWithLogs(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Look at Ollama container?
|
// TODO: Look at Ollama container?
|
||||||
// TODO: Look at MariaDB container?
|
// TODO: Look at MariaDB container?
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface ContainerConfig {
|
|||||||
};
|
};
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
proxyServerEnabled?: boolean;
|
proxyServerEnabled?: boolean;
|
||||||
|
taskRunner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:all": "playwright test",
|
"test:all": "playwright test",
|
||||||
"test:local": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=*ui*",
|
"test:local": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=ui --project=ui:isolated",
|
||||||
"test:ui": "playwright test --project=*ui*",
|
"test:ui": "playwright test --project=*ui*",
|
||||||
"test:performance": "playwright test --project=performance",
|
"test:performance": "playwright test --project=performance",
|
||||||
"test:chaos": "playwright test --project='*:chaos'",
|
"test:chaos": "playwright test --project='*:chaos'",
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import type { N8NConfig } from 'n8n-containers/n8n-test-container-creation';
|
|||||||
|
|
||||||
// Tags that require test containers environment
|
// Tags that require test containers environment
|
||||||
// These tests won't be run against local
|
// These tests won't be run against local
|
||||||
const CONTAINER_ONLY_TAGS = ['proxy', 'multi-node', 'postgres', 'queue', 'multi-main'];
|
const CONTAINER_ONLY_TAGS = [
|
||||||
|
'proxy',
|
||||||
|
'multi-node',
|
||||||
|
'postgres',
|
||||||
|
'queue',
|
||||||
|
'multi-main',
|
||||||
|
'task-runner',
|
||||||
|
];
|
||||||
const CONTAINER_ONLY = new RegExp(`@capability:(${CONTAINER_ONLY_TAGS.join('|')})`);
|
const CONTAINER_ONLY = new RegExp(`@capability:(${CONTAINER_ONLY_TAGS.join('|')})`);
|
||||||
|
|
||||||
// Tags that need serial execution
|
// Tags that need serial execution
|
||||||
@@ -11,11 +18,8 @@ const CONTAINER_ONLY = new RegExp(`@capability:(${CONTAINER_ONLY_TAGS.join('|')}
|
|||||||
// In local run they are a "dependency" which means they will be skipped if earlier tests fail, not ideal but needed for isolation
|
// In local run they are a "dependency" which means they will be skipped if earlier tests fail, not ideal but needed for isolation
|
||||||
const SERIAL_EXECUTION = /@db:reset/;
|
const SERIAL_EXECUTION = /@db:reset/;
|
||||||
|
|
||||||
// Tags that require proxy server
|
|
||||||
const REQUIRES_PROXY_SERVER = /@capability:proxy/;
|
|
||||||
|
|
||||||
const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
|
const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
|
||||||
{ name: 'standard', config: { proxyServerEnabled: true } },
|
{ name: 'standard', config: { proxyServerEnabled: true, taskRunner: true } },
|
||||||
{ name: 'postgres', config: { postgres: true } },
|
{ name: 'postgres', config: { postgres: true } },
|
||||||
{ name: 'queue', config: { queueMode: true } },
|
{ name: 'queue', config: { queueMode: true } },
|
||||||
{ name: 'multi-main', config: { queueMode: { mains: 2, workers: 1 } } },
|
{ name: 'multi-main', config: { queueMode: { mains: 2, workers: 1 } } },
|
||||||
@@ -38,7 +42,6 @@ export function getProjects(): Project[] {
|
|||||||
name: 'ui:isolated',
|
name: 'ui:isolated',
|
||||||
testDir: './tests/ui',
|
testDir: './tests/ui',
|
||||||
grep: SERIAL_EXECUTION,
|
grep: SERIAL_EXECUTION,
|
||||||
grepInvert: REQUIRES_PROXY_SERVER,
|
|
||||||
workers: 1,
|
workers: 1,
|
||||||
use: { baseURL: process.env.N8N_BASE_URL },
|
use: { baseURL: process.env.N8N_BASE_URL },
|
||||||
},
|
},
|
||||||
@@ -46,10 +49,6 @@ export function getProjects(): Project[] {
|
|||||||
} else {
|
} else {
|
||||||
for (const { name, config } of CONTAINER_CONFIGS) {
|
for (const { name, config } of CONTAINER_CONFIGS) {
|
||||||
const grepInvertPatterns = [SERIAL_EXECUTION.source];
|
const grepInvertPatterns = [SERIAL_EXECUTION.source];
|
||||||
if (!config.proxyServerEnabled) {
|
|
||||||
grepInvertPatterns.push(REQUIRES_PROXY_SERVER.source);
|
|
||||||
}
|
|
||||||
|
|
||||||
projects.push(
|
projects.push(
|
||||||
{
|
{
|
||||||
name: `${name}:ui`,
|
name: `${name}:ui`,
|
||||||
@@ -63,7 +62,6 @@ export function getProjects(): Project[] {
|
|||||||
name: `${name}:ui:isolated`,
|
name: `${name}:ui:isolated`,
|
||||||
testDir: './tests/ui',
|
testDir: './tests/ui',
|
||||||
grep: SERIAL_EXECUTION,
|
grep: SERIAL_EXECUTION,
|
||||||
grepInvert: !config.proxyServerEnabled ? REQUIRES_PROXY_SERVER : undefined,
|
|
||||||
workers: 1,
|
workers: 1,
|
||||||
use: { containerConfig: config },
|
use: { containerConfig: config },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { CODE_NODE_NAME, MANUAL_TRIGGER_NODE_NAME } from '../../config/constants';
|
||||||
|
import { test, expect } from '../../fixtures/base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Runner Capability Tests
|
||||||
|
*
|
||||||
|
* These tests require the task runner container to be running.
|
||||||
|
* Use @capability:task-runner tag to ensure they only run in task runner mode.
|
||||||
|
*/
|
||||||
|
test.describe('Task Runner Capability @capability:task-runner', () => {
|
||||||
|
test('should execute Javascript with task runner enabled', async ({ n8n }) => {
|
||||||
|
await n8n.goHome();
|
||||||
|
await n8n.workflows.clickAddWorkflowButton();
|
||||||
|
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
|
||||||
|
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
|
||||||
|
|
||||||
|
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
|
||||||
|
'Workflow executed successfully',
|
||||||
|
);
|
||||||
|
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should execute Python with task runner enabled', async ({ n8n }) => {
|
||||||
|
await n8n.goHome();
|
||||||
|
await n8n.workflows.clickAddWorkflowButton();
|
||||||
|
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
|
||||||
|
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in Python (Beta)', closeNDV: true });
|
||||||
|
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
|
||||||
|
'Workflow executed successfully',
|
||||||
|
);
|
||||||
|
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user