From b166445275ed8a7f40b14ce298aaea3e0dbe6a03 Mon Sep 17 00:00:00 2001 From: shortstacked Date: Mon, 8 Sep 2025 09:48:58 +0100 Subject: [PATCH] test: Add task runner to test containers (#19254) --- .../testing/containers/n8n-start-stack.ts | 16 ++++++ .../containers/n8n-test-container-creation.ts | 51 +++++++++++++++++-- .../n8n-test-container-dependencies.ts | 39 ++++++++++++++ packages/testing/playwright/fixtures/base.ts | 1 + packages/testing/playwright/package.json | 2 +- .../testing/playwright/playwright-projects.ts | 20 ++++---- .../tests/ui/task-runner-demo.spec.ts | 33 ++++++++++++ 7 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 packages/testing/playwright/tests/ui/task-runner-demo.spec.ts diff --git a/packages/testing/containers/n8n-start-stack.ts b/packages/testing/containers/n8n-start-stack.ts index 4a5d5e828a..619c5b6989 100755 --- a/packages/testing/containers/n8n-start-stack.ts +++ b/packages/testing/containers/n8n-start-stack.ts @@ -37,6 +37,7 @@ ${colors.yellow}Usage:${colors.reset} ${colors.yellow}Options:${colors.reset} --postgres Use PostgreSQL instead of SQLite --queue Enable queue mode (requires PostgreSQL) + --task-runner Enable external task runner container --mains Number of main instances (default: 1) --workers Number of worker instances (default: 1) --name Project name for parallel runs @@ -65,6 +66,9 @@ ${colors.yellow}Examples:${colors.reset} ${colors.bright}# Queue mode (automatically uses PostgreSQL)${colors.reset} npm run stack --queue + ${colors.bright}# With external task runner${colors.reset} + npm run stack --postgres --task-runner + ${colors.bright}# Custom scaling${colors.reset} npm run stack --queue --mains 3 --workers 5 @@ -96,6 +100,7 @@ async function main() { help: { type: 'boolean', short: 'h' }, postgres: { type: 'boolean' }, queue: { type: 'boolean' }, + 'task-runner': { type: 'boolean' }, mains: { type: 'string' }, workers: { type: 'string' }, name: { type: 'string' }, @@ -114,6 +119,7 @@ async function main() { // Build configuration const config: N8NConfig = { postgres: values.postgres ?? false, + taskRunner: values['task-runner'] ?? false, projectName: values.name ?? `n8n-stack-${Math.random().toString(36).substring(7)}`, }; @@ -225,6 +231,16 @@ function displayConfig(config: N8NConfig) { 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) { log.info( `Resource limits: ${config.resourceQuota.memory}GB RAM, ${config.resourceQuota.cpu} CPU cores`, diff --git a/packages/testing/containers/n8n-test-container-creation.ts b/packages/testing/containers/n8n-test-container-creation.ts index 23b83438b5..a5ea48544b 100644 --- a/packages/testing/containers/n8n-test-container-creation.ts +++ b/packages/testing/containers/n8n-test-container-creation.ts @@ -4,6 +4,7 @@ * - Single instances (SQLite or PostgreSQL) * - Queue mode with Redis * - Multi-main instances with load balancing + * - Task runner containers for external code execution * - Parallel execution (multiple stacks running simultaneously) * * Key features for parallel execution: @@ -23,6 +24,7 @@ import { setupCaddyLoadBalancer, pollContainerHttpEndpoint, setupProxyServer, + setupTaskRunner, } from './n8n-test-container-dependencies'; import { createSilentLogConsumer } from './n8n-test-container-utils'; @@ -45,7 +47,6 @@ const BASE_ENV: Record = { QUEUE_HEALTH_CHECK_ACTIVE: 'true', N8N_DIAGNOSTICS_ENABLED: 'false', 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?? N8N_LICENSE_TENANT_ID: process.env.N8N_LICENSE_TENANT_ID ?? '1001', N8N_LICENSE_ACTIVATION_KEY: process.env.N8N_LICENSE_ACTIVATION_KEY ?? '', @@ -81,6 +82,7 @@ export interface N8NConfig { cpu?: number; // in cores }; proxyServerEnabled?: boolean; + taskRunner?: boolean; } export interface N8NStack { @@ -119,15 +121,18 @@ export async function createN8NStack(config: N8NConfig = {}): Promise proxyServerEnabled = false, projectName, resourceQuota, + taskRunner = false, } = config; const queueConfig = normalizeQueueConfig(queueMode); + const taskRunnerEnabled = !!taskRunner; const usePostgres = postgres || !!queueConfig; const uniqueProjectName = projectName ?? `n8n-stack-${Math.random().toString(36).substring(7)}`; const containers: StartedTestContainer[] = []; const mainCount = queueConfig?.mains ?? 1; const needsLoadBalancer = mainCount > 1; - const needsNetwork = usePostgres || !!queueConfig || needsLoadBalancer || proxyServerEnabled; + const needsNetwork = + usePostgres || !!queueConfig || needsLoadBalancer || proxyServerEnabled || taskRunnerEnabled; let network: StartedNetwork | undefined; if (needsNetwork) { @@ -217,6 +222,16 @@ export async function createN8NStack(config: N8NConfig = {}): Promise }; } + 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; if (needsLoadBalancer) { @@ -269,6 +284,21 @@ export async function createN8NStack(config: N8NConfig = {}): Promise 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 { baseUrl, stop: async () => { @@ -414,6 +444,7 @@ async function createN8NContainer({ directPort, resourceQuota, }: CreateContainerOptions): Promise { + const taskRunnerEnabled = environment.N8N_RUNNERS_ENABLED === 'true'; const { consumer, throwWithLogs } = createSilentLogConsumer(); let container = new GenericContainer(N8N_IMAGE); @@ -445,13 +476,23 @@ async function createN8NContainer({ } 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 { - 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) { + const portMappings = taskRunnerEnabled + ? [{ container: 5678, host: directPort }, 5679] + : [{ container: 5678, host: directPort }]; container = container - .withExposedPorts({ container: 5678, host: directPort }) + .withExposedPorts(...portMappings) .withWaitStrategy(N8N_MAIN_WAIT_STRATEGY); } } diff --git a/packages/testing/containers/n8n-test-container-dependencies.ts b/packages/testing/containers/n8n-test-container-dependencies.ts index 1c96e394b7..06f57d5338 100644 --- a/packages/testing/containers/n8n-test-container-dependencies.ts +++ b/packages/testing/containers/n8n-test-container-dependencies.ts @@ -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 { + 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 MariaDB container? diff --git a/packages/testing/playwright/fixtures/base.ts b/packages/testing/playwright/fixtures/base.ts index 4aeb986078..171e15f861 100644 --- a/packages/testing/playwright/fixtures/base.ts +++ b/packages/testing/playwright/fixtures/base.ts @@ -34,6 +34,7 @@ interface ContainerConfig { }; env?: Record; proxyServerEnabled?: boolean; + taskRunner?: boolean; } /** diff --git a/packages/testing/playwright/package.json b/packages/testing/playwright/package.json index cb9f486d7a..d0bd994a48 100644 --- a/packages/testing/playwright/package.json +++ b/packages/testing/playwright/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "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:performance": "playwright test --project=performance", "test:chaos": "playwright test --project='*:chaos'", diff --git a/packages/testing/playwright/playwright-projects.ts b/packages/testing/playwright/playwright-projects.ts index 094339f757..7144716cac 100644 --- a/packages/testing/playwright/playwright-projects.ts +++ b/packages/testing/playwright/playwright-projects.ts @@ -3,7 +3,14 @@ import type { N8NConfig } from 'n8n-containers/n8n-test-container-creation'; // Tags that require test containers environment // 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('|')})`); // 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 const SERIAL_EXECUTION = /@db:reset/; -// Tags that require proxy server -const REQUIRES_PROXY_SERVER = /@capability:proxy/; - 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: 'queue', config: { queueMode: true } }, { name: 'multi-main', config: { queueMode: { mains: 2, workers: 1 } } }, @@ -38,7 +42,6 @@ export function getProjects(): Project[] { name: 'ui:isolated', testDir: './tests/ui', grep: SERIAL_EXECUTION, - grepInvert: REQUIRES_PROXY_SERVER, workers: 1, use: { baseURL: process.env.N8N_BASE_URL }, }, @@ -46,10 +49,6 @@ export function getProjects(): Project[] { } else { for (const { name, config } of CONTAINER_CONFIGS) { const grepInvertPatterns = [SERIAL_EXECUTION.source]; - if (!config.proxyServerEnabled) { - grepInvertPatterns.push(REQUIRES_PROXY_SERVER.source); - } - projects.push( { name: `${name}:ui`, @@ -63,7 +62,6 @@ export function getProjects(): Project[] { name: `${name}:ui:isolated`, testDir: './tests/ui', grep: SERIAL_EXECUTION, - grepInvert: !config.proxyServerEnabled ? REQUIRES_PROXY_SERVER : undefined, workers: 1, use: { containerConfig: config }, }, diff --git a/packages/testing/playwright/tests/ui/task-runner-demo.spec.ts b/packages/testing/playwright/tests/ui/task-runner-demo.spec.ts new file mode 100644 index 0000000000..5072392209 --- /dev/null +++ b/packages/testing/playwright/tests/ui/task-runner-demo.spec.ts @@ -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); + }); +});