test: Add task runner to test containers (#19254)

This commit is contained in:
shortstacked
2025-09-08 09:48:58 +01:00
committed by GitHub
parent 56f4069325
commit b166445275
7 changed files with 145 additions and 17 deletions

View File

@@ -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`,

View File

@@ -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);
} }
} }

View File

@@ -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?

View File

@@ -34,6 +34,7 @@ interface ContainerConfig {
}; };
env?: Record<string, string>; env?: Record<string, string>;
proxyServerEnabled?: boolean; proxyServerEnabled?: boolean;
taskRunner?: boolean;
} }
/** /**

View File

@@ -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'",

View File

@@ -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 },
}, },

View File

@@ -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);
});
});