feat: Add testcontainers and Playwright (no-changelog) (#16662)

Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
shortstacked
2025-07-01 14:15:31 +01:00
committed by GitHub
parent 422aa82524
commit 852657c17e
52 changed files with 5686 additions and 1111 deletions

4
.gitignore vendored
View File

@@ -26,6 +26,10 @@ build-storybook.log
junit.xml
test-results.json
*.0x
packages/testing/playwright/playwright-report
packages/testing/playwright/test-results
packages/testing/playwright/ms-playwright-cache
test-results/
compiled_app_output
trivy_report*
compiled

View File

@@ -17,6 +17,7 @@
"build:n8n": "node scripts/build-n8n.mjs",
"build:docker": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs",
"build:docker:scan": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs && node scripts/scan-n8n-image.mjs",
"build:docker:test": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs && pnpm --filter=n8n-playwright run test:standard",
"typecheck": "turbo typecheck",
"dev": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
@@ -101,7 +102,9 @@
"ws": ">=8.17.1",
"zod": "3.25.67",
"brace-expansion@1": "1.1.12",
"brace-expansion@2": "2.0.2"
"brace-expansion@2": "2.0.2",
"date-fns": "2.30.0",
"date-fns-tz": "2.0.0"
},
"patchedDependencies": {
"bull@4.16.4": "patches/bull@4.16.4.patch",

View File

@@ -229,14 +229,20 @@ export class E2EController {
}
private async truncateAll() {
const { connection } = this.settingsRepo.manager;
const dbType = connection.options.type;
for (const table of tablesToTruncate) {
try {
const { connection } = this.settingsRepo.manager;
await connection.query(
`DELETE FROM ${table}; DELETE FROM sqlite_sequence WHERE name=${table};`,
);
if (dbType === 'postgres') {
await connection.query(`TRUNCATE TABLE "${table}" RESTART IDENTITY CASCADE;`);
} else {
await connection.query(`DELETE FROM "${table}";`);
if (dbType === 'sqlite') {
await connection.query(`DELETE FROM sqlite_sequence WHERE name = '${table}';`);
}
}
} catch (error) {
Container.get(Logger).warn('Dropping Table for E2E Reset error', {
Container.get(Logger).warn(`Dropping Table "${table}" for E2E Reset error`, {
error: error as Error,
});
}

View File

@@ -0,0 +1,170 @@
# n8n Test Containers - Usage Guide
A simple way to spin up n8n container stacks for development and testing.
## Quick Start
```bash
# Start a basic n8n instance (SQLite database)
pnpm stack
# Start with PostgreSQL database
pnpm stack --postgres
# Start in queue mode (with Redis + PostgreSQL)
pnpm stack --queue
```
When started, you'll see:
- **URL**: http://localhost:[random-port]
## Common Usage Patterns
### Development with Container Reuse
```bash
# Enable container reuse (faster restarts)
pnpm run dev # SQLite
pnpm run dev:postgres # PostgreSQL
pnpm run dev:queue # Queue mode
pnpm run dev:multi-main # Multiple main instances
```
### Queue Mode with Scaling
```bash
# Custom scaling: 3 main instances, 5 workers
pnpm stack --queue --mains 3 --workers 5
# Single main, 2 workers
pnpm stack --queue --workers 2
```
### Environment Variables
```bash
# Set custom environment variables
pnpm run stack --postgres --env N8N_LOG_LEVEL=info --env N8N_ENABLED_MODULES=insights
```
### Parallel Testing
```bash
# Run multiple stacks in parallel with unique names
pnpm run stack --name test-1 --postgres
pnpm run stack --name test-2 --queue
```
## Custom Container Config
### Via Command Line
```bash
# Pass any n8n env vars to containers
N8N_TEST_ENV='{"N8N_METRICS":"true"}' npm run stack:standard
N8N_TEST_ENV='{"N8N_LOG_LEVEL":"debug","N8N_METRICS":"true","N8N_ENABLED_MODULES":"insights"}' npm run stack:postgres
```
## Programmatic Usage
```typescript
import { createN8NStack } from './containers/n8n-test-containers';
// Simple SQLite instance
const stack = await createN8NStack();
// PostgreSQL with custom environment
const stack = await createN8NStack({
postgres: true,
env: { N8N_LOG_LEVEL: 'debug' }
});
// Queue mode with scaling
const stack = await createN8NStack({
queueMode: { mains: 2, workers: 3 }
});
// Use the stack
console.log(`n8n available at: ${stack.baseUrl}`);
// Clean up when done
await stack.stop();
```
## Configuration Options
| Option | Description | Example |
|--------|-------------|---------|
| `--postgres` | Use PostgreSQL instead of SQLite | `npm run stack -- --postgres` |
| `--queue` | Enable queue mode with Redis | `npm run stack -- --queue` |
| `--mains <n>` | Number of main instances (requires queue mode) | `--mains 3` |
| `--workers <n>` | Number of worker instances (requires queue mode) | `--workers 5` |
| `--name <name>` | Custom project name for parallel runs | `--name my-test` |
| `--env KEY=VALUE` | Set environment variables | `--env N8N_LOG_LEVEL=debug` |
## Container Architecture
### Single Instance (Default)
```
┌─────────────┐
│ n8n │ ← SQLite database
│ (SQLite) │
└─────────────┘
```
### With PostgreSQL
```
┌─────────────┐ ┌──────────────┐
│ n8n │────│ PostgreSQL │
│ │ │ │
└─────────────┘ └──────────────┘
```
### Queue Mode
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ n8n-main │────│ PostgreSQL │ │ Redis │
└─────────────┘ └──────────────┘ └─────────────┘
┌─────────────┐ │ │
│ n8n-worker │────────────────────────┘ │
└─────────────┘ │
┌─────────────┐ │
│ n8n-worker │──────────────────────────────────────┘
└─────────────┘
```
### Multi-Main with Load Balancer
```
┌──────────────┐
────│ nginx │ ← Entry point
/ │ Load Balancer│
┌─────────────┐ └──────────────┘
│ n8n-main-1 │────┐
└─────────────┘ │ ┌──────────────┐ ┌─────────────┐
┌─────────────┐ ├─│ PostgreSQL │ │ Redis │
│ n8n-main-2 │────┤ └──────────────┘ └─────────────┘
└─────────────┘ │ │ │
┌─────────────┐ │ ┌─────────────────────────────────┤
│ n8n-worker │────┘ │ │
└─────────────┘ └─────────────────────────────────┘
```
## Cleanup
```bash
# Remove all n8n containers and networks
pnpm run stack:clean:all
## Tips
- **Container Reuse**: Set `TESTCONTAINERS_REUSE_ENABLE=true` for faster development cycles
- **Parallel Testing**: Use `--name` parameter to run multiple stacks without conflicts
- **Queue Mode**: Automatically enables PostgreSQL (required for queue mode)
- **Multi-Main**: Requires queue mode and special licensing read from N8N_LICENSE_ACTIVATION_KEY environment variable
- **Log Monitoring**: Use the `ContainerTestHelpers` class for advanced log monitoring in tests
## Docker Image
By default, uses the `n8nio/n8n:local` image. Override with:
```bash
export N8N_DOCKER_IMAGE=n8nio/n8n:dev
pnpm run stack
```

View File

@@ -0,0 +1,22 @@
// 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 = process.env.N8N_DOCKER_IMAGE ?? 'n8nio/n8n:local';
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>
Example with different image:
N8N_DOCKER_IMAGE=n8nio/n8n:latest npm run stack`;
super(message);
this.name = 'DockerImageNotFoundError';
this.cause = originalError;
}
}

View File

@@ -0,0 +1,202 @@
#!/usr/bin/env tsx
import { parseArgs } from 'node:util';
import type { N8NConfig, N8NStack } from './n8n-test-container-creation';
import { createN8NStack } from './n8n-test-container-creation';
import { DockerImageNotFoundError } from './docker-image-not-found-error';
// ANSI colors for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
red: '\x1b[31m',
cyan: '\x1b[36m',
};
const log = {
info: (msg: string) => console.log(`${colors.blue}${colors.reset} ${msg}`),
success: (msg: string) => console.log(`${colors.green}${colors.reset} ${msg}`),
error: (msg: string) => console.error(`${colors.red}${colors.reset} ${msg}`),
warn: (msg: string) => console.warn(`${colors.yellow}${colors.reset} ${msg}`),
header: (msg: string) => console.log(`\n${colors.bright}${colors.cyan}${msg}${colors.reset}\n`),
};
function showHelp() {
console.log(`
${colors.bright}n8n Stack Manager${colors.reset}
Start n8n containers for development and testing.
${colors.yellow}Usage:${colors.reset}
npm run stack [options]
${colors.yellow}Options:${colors.reset}
--postgres Use PostgreSQL instead of SQLite
--queue Enable queue mode (requires PostgreSQL)
--mains <n> Number of main instances (default: 1)
--workers <n> Number of worker instances (default: 1)
--name <name> Project name for parallel runs
--env KEY=VALUE Set environment variables
--help, -h Show this help
${colors.yellow}Examples:${colors.reset}
${colors.bright}# Simple SQLite instance${colors.reset}
npm run stack
${colors.bright}# PostgreSQL database${colors.reset}
npm run stack --postgres
${colors.bright}# Queue mode (automatically uses PostgreSQL)${colors.reset}
npm run stack --queue
${colors.bright}# Custom scaling${colors.reset}
npm run stack --queue --mains 3 --workers 5
${colors.bright}# With environment variables${colors.reset}
npm run stack --postgres --env N8N_LOG_LEVEL=info --env N8N_ENABLED_MODULES=insights
${colors.bright}# Parallel instances${colors.reset}
npm run stack --name test-1
npm run stack --name test-2
${colors.yellow}Notes:${colors.reset}
• SQLite is the default database (no external dependencies)
• Queue mode requires PostgreSQL and enables horizontal scaling
• Use --name for running multiple instances in parallel
• Press Ctrl+C to stop all containers
`);
}
async function main() {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
help: { type: 'boolean', short: 'h' },
postgres: { type: 'boolean' },
queue: { type: 'boolean' },
mains: { type: 'string' },
workers: { type: 'string' },
name: { type: 'string' },
env: { type: 'string', multiple: true },
},
allowPositionals: false,
});
// Show help if requested
if (values.help) {
showHelp();
process.exit(0);
}
// Build configuration
const config: N8NConfig = {
postgres: values.postgres ?? false,
projectName: values.name ?? `n8n-stack-${Math.random().toString(36).substring(7)}`,
};
// Handle queue mode
if (values.queue ?? values.mains ?? values.workers) {
const mains = parseInt(values.mains ?? '1', 10);
const workers = parseInt(values.workers ?? '1', 10);
if (isNaN(mains) || isNaN(workers) || mains < 1 || workers < 0) {
log.error('Invalid mains or workers count');
process.exit(1);
}
config.queueMode = { mains, workers };
if (!values.queue && (values.mains ?? values.workers)) {
log.warn('--mains and --workers imply queue mode');
}
}
// Parse environment variables
if (values.env && values.env.length > 0) {
config.env = {};
for (const envStr of values.env) {
const [key, ...valueParts] = envStr.split('=');
const value = valueParts.join('='); // Handle values with = in them
if (key && value) {
config.env[key] = value;
} else {
log.warn(`Invalid env format: ${envStr} (expected KEY=VALUE)`);
}
}
}
log.header('Starting n8n Stack');
log.info(`Project name: ${config.projectName}`);
displayConfig(config);
let stack: N8NStack;
try {
log.info('Starting containers...');
try {
stack = await createN8NStack(config);
} catch (error) {
if (error instanceof DockerImageNotFoundError) {
log.error(error.message);
process.exit(1);
}
throw error;
}
log.success('All containers started successfully!');
console.log('');
log.info(`n8n URL: ${colors.bright}${colors.green}${stack.baseUrl}${colors.reset}`);
} catch (error) {
log.error(`Failed to start: ${error as string}`);
process.exit(1);
}
}
function displayConfig(config: N8NConfig) {
const dockerImage = process.env.N8N_DOCKER_IMAGE ?? 'n8nio/n8n:local';
log.info(`Docker image: ${dockerImage}`);
// Determine actual database
const usePostgres = config.postgres || config.queueMode;
log.info(`Database: ${usePostgres ? 'PostgreSQL' : 'SQLite'}`);
if (config.queueMode) {
const qm = typeof config.queueMode === 'boolean' ? { mains: 1, workers: 1 } : config.queueMode;
log.info(`Queue mode: ${qm.mains} main(s), ${qm.workers} worker(s)`);
if (!config.postgres) {
log.info('(PostgreSQL automatically enabled for queue mode)');
}
if (qm.mains && qm.mains > 1) {
log.info('(nginx load balancer will be configured)');
}
} else {
log.info('Queue mode: disabled');
}
if (config.env) {
const envCount = Object.keys(config.env).length;
if (envCount > 0) {
log.info(`Environment variables: ${envCount} custom variable(s)`);
Object.entries(config.env).forEach(([key, value]) => {
console.log(` ${key}=${value as string}`);
});
}
}
if (process.env.TESTCONTAINERS_REUSE_ENABLE === 'true') {
log.info('Container reuse: enabled (containers will persist)');
}
}
// Run if executed directly
if (require.main === module) {
main().catch((error) => {
log.error(`Unexpected error: ${error}`);
process.exit(1);
});
}

View File

@@ -0,0 +1,340 @@
/**
* n8n Test Containers Setup
* This file provides a complete n8n container stack for testing with support for:
* - Single instances (SQLite or PostgreSQL)
* - Queue mode with Redis
* - Multi-main instances with nginx load balancing
* - Parallel execution (multiple stacks running simultaneously)
*
* Key features for parallel execution:
* - Dynamic port allocation to avoid conflicts (handled by testcontainers)
* - WebSocket support through nginx load balancer
*/
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Network, Wait } from 'testcontainers';
import {
setupNginxLoadBalancer,
setupPostgres,
setupRedis,
} from './n8n-test-container-dependencies';
import { DockerImageNotFoundError } from './docker-image-not-found-error';
// --- Constants ---
const POSTGRES_IMAGE = 'postgres:16-alpine';
const REDIS_IMAGE = 'redis:7-alpine';
const NGINX_IMAGE = 'nginx:stable';
const N8N_E2E_IMAGE = 'n8nio/n8n:local';
// Default n8n image (can be overridden via N8N_DOCKER_IMAGE env var)
const N8N_IMAGE = process.env.N8N_DOCKER_IMAGE || N8N_E2E_IMAGE;
// Base environment for all n8n instances
const BASE_ENV: Record<string, string> = {
N8N_LOG_LEVEL: 'debug',
N8N_ENCRYPTION_KEY: 'test-encryption-key',
E2E_TESTS: 'true',
QUEUE_HEALTH_CHECK_ACTIVE: 'true',
N8N_DIAGNOSTICS_ENABLED: 'false',
NODE_ENV: 'development', // If this is set to test, the n8n container will not start, insights module is not found??
};
const MULTI_MAIN_LICENSE = {
N8N_LICENSE_TENANT_ID: '1001',
N8N_LICENSE_ACTIVATION_KEY: process.env.N8N_LICENSE_ACTIVATION_KEY ?? '',
};
// Wait strategy for n8n containers
const N8N_WAIT_STRATEGY = Wait.forAll([
Wait.forListeningPorts(),
Wait.forHttp('/healthz/readiness', 5678).forStatusCode(200).withStartupTimeout(90000),
]);
// --- Interfaces ---
export interface N8NConfig {
postgres?: boolean;
queueMode?:
| boolean
| {
mains?: number;
workers?: number;
};
env?: Record<string, string>;
projectName?: string;
}
export interface N8NStack {
baseUrl: string;
stop: () => Promise<void>;
containers: StartedTestContainer[];
}
/**
* Create an n8n container stack
*
* @example
* // Simple SQLite instance
* const stack = await createN8NStack();
*
* @example
* // PostgreSQL without queue mode
* const stack = await createN8NStack({ postgres: true });
*
* @example
* // Queue mode (automatically uses PostgreSQL)
* const stack = await createN8NStack({ queueMode: true });
*
* @example
* // Custom scaling
* const stack = await createN8NStack({
* queueMode: { mains: 3, workers: 5 },
* env: { N8N_ENABLED_MODULES: 'insights' }
* });
*/
export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack> {
const { postgres = false, queueMode = false, env = {}, projectName } = config;
const queueConfig = normalizeQueueConfig(queueMode);
const usePostgres = postgres || !!queueConfig;
const uniqueProjectName = projectName ?? `n8n-${Math.random().toString(36).substring(7)}`;
const containers: StartedTestContainer[] = [];
let network: StartedNetwork | undefined;
let nginxContainer: StartedTestContainer | undefined;
let environment: Record<string, string> = { ...BASE_ENV, ...env };
if (usePostgres || queueConfig) {
network = await new Network().start();
}
if (usePostgres) {
const postgresContainer = await setupPostgres({
postgresImage: POSTGRES_IMAGE,
projectName: uniqueProjectName,
network: network!,
});
containers.push(postgresContainer.container);
environment = {
...environment,
DB_TYPE: 'postgresdb',
DB_POSTGRESDB_HOST: 'postgres',
DB_POSTGRESDB_PORT: '5432',
DB_POSTGRESDB_DATABASE: postgresContainer.database,
DB_POSTGRESDB_USER: postgresContainer.username,
DB_POSTGRESDB_PASSWORD: postgresContainer.password,
};
} else {
environment.DB_TYPE = 'sqlite';
}
if (queueConfig) {
const redis = await setupRedis({
redisImage: REDIS_IMAGE,
projectName: uniqueProjectName,
network: network!,
});
containers.push(redis);
environment = {
...environment,
EXECUTIONS_MODE: 'queue',
QUEUE_BULL_REDIS_HOST: 'redis',
QUEUE_BULL_REDIS_PORT: '6379',
};
if (queueConfig.mains > 1) {
if (!process.env.N8N_LICENSE_ACTIVATION_KEY) {
throw new Error('N8N_LICENSE_ACTIVATION_KEY is required for multi-main instances');
}
environment = {
...environment,
N8N_PROXY_HOPS: '1',
N8N_MULTI_MAIN_SETUP_ENABLED: 'true',
...MULTI_MAIN_LICENSE,
};
}
}
let baseUrl: string;
const instances = await createN8NInstances({
mainCount: queueConfig?.mains ?? 1,
workerCount: queueConfig?.workers ?? 0,
uniqueProjectName: uniqueProjectName,
environment,
network,
});
containers.push(...instances);
if (queueConfig && queueConfig.mains > 1) {
nginxContainer = await setupNginxLoadBalancer({
nginxImage: NGINX_IMAGE,
projectName: uniqueProjectName,
mainInstances: instances.slice(0, queueConfig.mains),
network: network!,
});
containers.push(nginxContainer);
baseUrl = `http://localhost:${nginxContainer.getMappedPort(80)}`;
} else {
baseUrl = `http://localhost:${instances[0].getMappedPort(5678)}`;
}
return {
baseUrl,
stop: async () => {
await stopN8NStack(containers, network, uniqueProjectName);
},
containers,
};
}
async function stopN8NStack(
containers: StartedTestContainer[],
network: StartedNetwork | undefined,
uniqueProjectName: string,
): Promise<void> {
const errors: Error[] = [];
try {
const stopPromises = containers.reverse().map(async (container) => {
try {
await container.stop();
} catch (error) {
errors.push(new Error(`Failed to stop container ${container.getId()}: ${error as string}`));
}
});
await Promise.allSettled(stopPromises);
if (network) {
try {
await network.stop();
} catch (error) {
errors.push(new Error(`Failed to stop network ${network.getName()}: ${error as string}`));
}
}
if (errors.length > 0) {
console.warn(
`Some cleanup operations failed for stack ${uniqueProjectName}:`,
errors.map((e) => e.message).join(', '),
);
}
} catch (error) {
console.error(`Critical error during cleanup for stack ${uniqueProjectName}:`, error);
throw error;
}
}
function normalizeQueueConfig(
queueMode: boolean | { mains?: number; workers?: number },
): { mains: number; workers: number } | null {
if (!queueMode) return null;
if (typeof queueMode === 'boolean') {
return { mains: 1, workers: 1 };
}
return {
mains: queueMode.mains ?? 1,
workers: queueMode.workers ?? 1,
};
}
interface CreateInstancesOptions {
mainCount: number;
workerCount: number;
uniqueProjectName: string;
environment: Record<string, string>;
network?: StartedNetwork;
}
async function createN8NInstances({
mainCount,
workerCount,
uniqueProjectName,
environment,
network,
}: CreateInstancesOptions): Promise<StartedTestContainer[]> {
const instances: StartedTestContainer[] = [];
for (let i = 1; i <= mainCount; i++) {
const name = mainCount > 1 ? `${uniqueProjectName}-n8n-main-${i}` : `${uniqueProjectName}-n8n`;
const container = await createN8NContainer({
name,
uniqueProjectName,
environment,
network,
isWorker: false,
instanceNumber: i,
networkAlias: mainCount > 1 ? name : undefined,
});
instances.push(container);
}
for (let i = 1; i <= workerCount; i++) {
const name = `${uniqueProjectName}-n8n-worker-${i}`;
const container = await createN8NContainer({
name,
uniqueProjectName,
environment,
network: network!,
isWorker: true,
instanceNumber: i,
});
instances.push(container);
}
return instances;
}
interface CreateContainerOptions {
name: string;
uniqueProjectName: string;
environment: Record<string, string>;
network?: StartedNetwork;
isWorker: boolean;
instanceNumber: number;
networkAlias?: string;
}
async function createN8NContainer({
name,
uniqueProjectName,
environment,
network,
isWorker,
instanceNumber,
networkAlias,
}: CreateContainerOptions): Promise<StartedTestContainer> {
let container = new GenericContainer(N8N_IMAGE);
container = container
.withEnvironment(environment)
.withLabels({
'com.docker.compose.project': uniqueProjectName,
'com.docker.compose.service': isWorker ? 'n8n-worker' : 'n8n-main',
instance: instanceNumber.toString(),
})
.withName(name)
.withReuse();
if (isWorker) {
container = container.withCommand(['worker']);
} else {
container = container.withExposedPorts(5678).withWaitStrategy(N8N_WAIT_STRATEGY);
}
if (network) {
container = container.withNetwork(network);
if (networkAlias) {
container = container.withNetworkAliases(networkAlias);
}
}
try {
return await container.start();
} catch (error) {
if (error instanceof Error && 'statusCode' in error && error.statusCode === 404) {
throw new DockerImageNotFoundError(name, error);
}
throw error;
}
}

View File

@@ -0,0 +1,189 @@
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { RedisContainer } from '@testcontainers/redis';
import type { StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer, Wait } from 'testcontainers';
export async function setupRedis({
redisImage,
projectName,
network,
}: {
redisImage: string;
projectName: string;
network: StartedNetwork;
}): Promise<StartedTestContainer> {
return await new RedisContainer(redisImage)
.withNetwork(network)
.withNetworkAliases('redis')
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'redis',
})
.withName(`${projectName}-redis`)
.withReuse()
.start();
}
export async function setupPostgres({
postgresImage,
projectName,
network,
}: {
postgresImage: string;
projectName: string;
network: StartedNetwork;
}): Promise<{
container: StartedTestContainer;
database: string;
username: string;
password: string;
}> {
const postgres = await new PostgreSqlContainer(postgresImage)
.withNetwork(network)
.withNetworkAliases('postgres')
.withDatabase('n8n_db')
.withUsername('n8n_user')
.withPassword('test_password')
.withStartupTimeout(30000)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'postgres',
})
.withName(`${projectName}-postgres`)
.withReuse()
.start();
return {
container: postgres,
database: postgres.getDatabase(),
username: postgres.getUsername(),
password: postgres.getPassword(),
};
}
/**
* Setup NGINX for multi-main instances
* @param nginxImage The Docker image for NGINX.
* @param uniqueSuffix A unique suffix for naming and labeling.
* @param mainInstances An array of running backend container instances.
* @param network The shared Docker network.
* @param nginxPort The host port to expose for NGINX.
* @returns A promise that resolves to the started NGINX container.
*/
export async function setupNginxLoadBalancer({
nginxImage,
projectName,
mainInstances,
network,
}: {
nginxImage: string;
projectName: string;
mainInstances: StartedTestContainer[];
network: StartedNetwork;
}): Promise<StartedTestContainer> {
// Generate upstream server entries from the list of main instances.
const upstreamServers = mainInstances
.map((_, index) => ` server ${projectName}-n8n-main-${index + 1}:5678;`)
.join('\n');
// Build the NGINX configuration with dynamic upstream servers.
// This allows us to have the port allocation be dynamic.
const nginxConfig = buildNginxConfig(upstreamServers);
return await new GenericContainer(nginxImage)
.withNetwork(network)
.withExposedPorts(80)
.withCopyContentToContainer([{ content: nginxConfig, target: '/etc/nginx/nginx.conf' }])
.withWaitStrategy(Wait.forListeningPorts())
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': 'nginx-lb',
})
.withName(`${projectName}-nginx-lb`)
.withReuse()
.start();
}
/**
* Builds NGINX configuration for load balancing n8n instances
* @param upstreamServers The upstream server entries to include in the configuration
* @returns The complete NGINX configuration as a string
*/
function buildNginxConfig(upstreamServers: string): string {
return `
events {
worker_connections 1024;
}
http {
client_max_body_size 50M;
access_log off;
error_log /dev/stderr warn;
# Map for WebSocket upgrades
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend {
# Use ip_hash for sticky sessions
ip_hash;
${upstreamServers}
keepalive 32;
}
server {
listen 80;
# Set longer timeouts for slow operations
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
location / {
proxy_pass http://backend;
# Forward standard proxy headers
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Forward WebSocket headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_http_version 1.1;
proxy_buffering off;
}
# Specific location for real-time push/websockets
location /rest/push {
proxy_pass http://backend;
# Forward standard proxy headers
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Configure WebSocket proxying
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
# Disable buffering for real-time data
proxy_buffering off;
# Set very long timeouts for persistent connections
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
}`;
}
// TODO: Look at Ollama container?
// TODO: Look at MariaDB container?
// TODO: Look at MockServer container, could we use this for mocking out external services?

View File

@@ -0,0 +1,377 @@
import type { StartedTestContainer, StoppedTestContainer } from 'testcontainers';
export interface LogMatch {
container: StartedTestContainer;
containerName: string;
message: string;
timestamp: Date;
}
interface WaitForLogOptions {
namePattern?: string | RegExp;
timeoutMs?: number;
caseSensitive?: boolean;
}
interface StreamLogMatch {
line: string;
date: Date | null;
}
/**
* Container helpers bound to a specific set of containers
*/
export class ContainerTestHelpers {
private static readonly DEFAULT_TIMEOUT_MS = 30000;
private static readonly POLL_INTERVAL_MS = 1000;
// Containers
private containers: StartedTestContainer[];
constructor(containers: StartedTestContainer[]) {
this.containers = containers;
}
/**
* Read logs from a container
*/
async readLogs(containerNamePattern: string | RegExp, since?: number): Promise<string> {
const container = this.findContainers(containerNamePattern)[0];
if (!container) {
console.warn(`No container found matching pattern: ${containerNamePattern}`);
return '';
}
return await this.readLogsFromContainer(container, since);
}
/**
* Wait for a log message matching pattern (case-insensitive by default)
* Uses streaming approach for immediate detection
*/
async waitForLog(
messagePattern: string | RegExp,
options: WaitForLogOptions = {},
): Promise<LogMatch> {
const {
namePattern,
timeoutMs = ContainerTestHelpers.DEFAULT_TIMEOUT_MS,
caseSensitive = false,
} = options;
const messageRegex = this.createRegex(messagePattern, caseSensitive);
const targetContainers = namePattern ? this.findContainers(namePattern) : this.containers;
const startTime = Date.now();
console.log(
`🔍 Waiting for log pattern: ${messageRegex} in ${targetContainers.length} containers (timeout: ${timeoutMs}ms)`,
);
// First check: scan existing logs quickly
const existingMatch = await this.findFirstMatchingLog(targetContainers, messageRegex);
if (existingMatch) {
console.log(`✅ Found existing log in ${existingMatch.containerName}`);
return existingMatch;
}
// Monitor new logs with streaming approach
return await this.pollForNewLogs(targetContainers, messageRegex, startTime, timeoutMs);
}
/**
* Find the first matching log across multiple containers
*/
private async findFirstMatchingLog(
containers: StartedTestContainer[],
messageRegex: RegExp,
sinceTimestamp?: number,
): Promise<LogMatch | null> {
const matchPromises = containers.map(async (container) => {
const match = await this.findLogInContainer(container, messageRegex, sinceTimestamp);
if (match) {
return {
container,
containerName: container.getName(),
message: match.line,
timestamp: match.date ?? new Date(),
};
}
return null;
});
const results = await Promise.all(matchPromises);
return results.find((result) => result !== null) ?? null;
}
/**
* Poll containers for new logs matching the pattern
*/
private async pollForNewLogs(
targetContainers: StartedTestContainer[],
messageRegex: RegExp,
startTime: number,
timeoutMs: number,
): Promise<LogMatch> {
let currentCheckTime = Math.floor(Date.now() / 1000);
let iteration = 0;
while (Date.now() - startTime < timeoutMs) {
iteration++;
await this.sleep(ContainerTestHelpers.POLL_INTERVAL_MS);
// Capture the timestamp for this iteration to avoid race conditions
const checkTimestamp = currentCheckTime;
// Check all containers concurrently
const matchPromises = targetContainers.map((container) =>
this.checkContainerForMatch(container, messageRegex, checkTimestamp),
);
const results = await Promise.all(matchPromises);
const found = results.find((result) => result !== null);
if (found) {
console.log(`✅ Found new log in ${found.containerName} (iteration ${iteration})`);
return found;
}
// Update timestamp for next iteration
currentCheckTime = Math.floor(Date.now() / 1000);
// Progress indicator
if (iteration % 10 === 0) {
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
console.log(`⏱️ Still waiting... (${elapsedSeconds}s elapsed)`);
}
}
console.log(`❌ Timeout reached after ${timeoutMs}ms`);
throw new Error(`Timeout reached after ${timeoutMs}ms`);
}
/**
* Check a single container for matching logs
*/
private async checkContainerForMatch(
container: StartedTestContainer,
messageRegex: RegExp,
sinceTimestamp: number,
): Promise<LogMatch | null> {
const match = await this.findLogInContainer(container, messageRegex, sinceTimestamp);
if (!match) {
return null;
}
return {
container,
containerName: container.getName(),
message: match.line,
timestamp: match.date ?? new Date(),
};
}
/**
* Get all log messages matching pattern (case-insensitive by default)
*/
async getLogs(
messagePattern: string | RegExp,
namePattern?: string | RegExp,
caseSensitive = false,
): Promise<LogMatch[]> {
const messageRegex = this.createRegex(messagePattern, caseSensitive);
const targetContainers = namePattern ? this.findContainers(namePattern) : this.containers;
console.log(
`🔍 Getting all logs matching: ${messageRegex} from ${targetContainers.length} containers`,
);
const logPromises = targetContainers.map(async (container) => {
const logs = await this.readLogsFromContainer(container);
return this.findAllLogMatches(logs, messageRegex, container);
});
const results = await Promise.all(logPromises);
const matches = results.flat();
console.log(`📈 Total matches found: ${matches.length}`);
return matches;
}
/**
* Find containers by name pattern
*/
findContainers(namePattern: string | RegExp): StartedTestContainer[] {
const regex = typeof namePattern === 'string' ? new RegExp(namePattern) : namePattern;
const foundContainers = this.containers.filter((container) => regex.test(container.getName()));
console.log(`🔎 Found ${foundContainers.length} containers matching pattern`);
return foundContainers;
}
/**
* Stop container by name pattern
*/
async stopContainer(namePattern: string | RegExp): Promise<StoppedTestContainer | null> {
const container = this.findContainers(namePattern)[0];
return container ? await container.stop() : null;
}
// Private helper methods
private createRegex(pattern: string | RegExp, caseSensitive: boolean): RegExp {
return typeof pattern === 'string' ? new RegExp(pattern, caseSensitive ? 'g' : 'gi') : pattern;
}
/**
* Strip ANSI escape codes from log text
*/
private stripAnsiCodes(text: string): string {
return text.replace(/\x1B\[[0-9;]*[mGKH]/g, '');
}
/**
* Extract timestamp from log line
*/
private extractTimestamp(line: string): Date | null {
const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)/);
return timestampMatch ? new Date(timestampMatch[1]) : null;
}
/**
* Find log line in container using streaming approach with early exit
*/
private async findLogInContainer(
container: StartedTestContainer,
messageRegex: RegExp,
since?: number,
): Promise<StreamLogMatch | null> {
try {
const logOptions: any = {};
if (since !== undefined) {
logOptions.since = since;
}
const stream = await container.logs(logOptions);
return await new Promise((resolve, reject) => {
let buffer = '';
const timeout = setTimeout(() => {
stream.destroy();
resolve(null); // Timeout means no match found
}, 5000); // Shorter timeout for individual container checks
const onData = (chunk: Buffer | string) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
// Keep the last incomplete line in buffer
buffer = lines.pop() ?? '';
// Check complete lines
for (const line of lines) {
const cleanLine = this.stripAnsiCodes(line.trim());
if (cleanLine && messageRegex.test(cleanLine)) {
clearTimeout(timeout);
stream.destroy();
resolve({
line: cleanLine,
date: this.extractTimestamp(cleanLine),
});
return;
}
}
};
stream.on('data', onData);
stream.on('end', () => {
clearTimeout(timeout);
stream.destroy();
resolve(null); // No match found
});
stream.on('error', (error) => {
clearTimeout(timeout);
stream.destroy();
console.error(`❌ Stream error from ${container.getName()}:`, error);
reject(error);
});
});
} catch (error) {
console.warn(`❌ Failed to search logs from ${container.getName()}: ${error as string}`);
return null;
}
}
private async readLogsFromContainer(
container: StartedTestContainer,
since?: number,
): Promise<string> {
try {
const logOptions: any = {};
if (since !== undefined) {
logOptions.since = since;
}
const stream = await container.logs(logOptions);
let allData = '';
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
stream.destroy();
reject(new Error('Log read timeout'));
}, 10000);
stream.on('data', (chunk: Buffer | string) => {
allData += chunk.toString();
});
stream.on('end', () => {
clearTimeout(timeout);
stream.destroy();
resolve(this.stripAnsiCodes(allData));
});
stream.on('error', (error) => {
clearTimeout(timeout);
stream.destroy();
console.error(`❌ Stream error from ${container.getName()}:`, error);
reject(error);
});
});
} catch (error) {
console.warn(`❌ Failed to read logs from ${container.getName()}: ${error as string}`);
return '';
}
}
private findAllLogMatches(
logs: string,
messageRegex: RegExp,
container: StartedTestContainer,
): LogMatch[] {
const lines = logs.split('\n').filter((line) => line.trim());
const matches: LogMatch[] = [];
for (const line of lines) {
const cleanLine = line.trim();
if (messageRegex.test(cleanLine)) {
matches.push({
container,
containerName: container.getName(),
message: cleanLine,
timestamp: this.extractTimestamp(cleanLine) ?? new Date(),
});
}
}
return matches;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,23 @@
{
"name": "n8n-containers",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"stack": "tsx ./n8n-start-stack.ts",
"stack:help": "tsx ./n8n-start-stack.ts --help",
"dev": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack",
"dev:postgres": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack -- --postgres",
"dev:queue": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack -- --queue",
"dev:multi-main": "TESTCONTAINERS_REUSE_ENABLE=true npm run stack -- --mains 2 --workers 1",
"stack:clean:all": "docker rm -f $(docker ps -aq --filter 'name=n8n-*') 2>/dev/null || true && docker network prune -f"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@testcontainers/postgresql": "^11.0.3",
"@testcontainers/redis": "^11.0.3",
"testcontainers": "^11.0.3"
}
}

View File

@@ -0,0 +1,41 @@
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n/eslint-config/base', 'plugin:playwright/recommended'],
...sharedOptions(__dirname),
plugins: ['playwright'],
env: {
node: true,
},
rules: {
// TODO: remove these rules
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/promise-function-async': 'off',
'n8n-local-rules/no-uncaught-json-parse': 'off',
'playwright/expect-expect': 'warn',
'playwright/max-nested-describe': 'warn',
'playwright/no-conditional-in-test': 'warn',
'playwright/no-skipped-test': 'warn',
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: ['**/tests/**', '**/e2e/**', '**/playwright/**'],
optionalDependencies: false,
},
],
},
};

View File

@@ -0,0 +1,41 @@
# Playwright E2E Test Guide
## Quick Start
```bash
pnpm test # Run all tests (fresh containers)
pnpm run test:local # Run against http://localhost:5678
```
## Test Commands
```bash
# By Mode
pnpm run test:standard # Basic n8n
pnpm run test:postgres # PostgreSQL
pnpm run test:queue # Queue mode
pnpm run test:multi-main # HA setup
# Development
pnpm test --grep "workflow" # Pattern match
```
## Test Tags
```typescript
test('basic test', ...) // All modes, fully parallel
test('postgres only @mode:postgres', ...) // Mode-specific
test('needs clean db @db:reset', ...) // Sequential per worker
test('chaos test @mode:multi-main @chaostest', ...) // Isolated per worker
```
## Tips
- `test:*` commands use fresh containers (for testing)
- VS Code: Set `N8N_BASE_URL` in Playwright settings to run tests directly from VS Code
- Pass custom env vars via `N8N_TEST_ENV='{"KEY":"value"}'`
## Project Layout
- **composables**: Multi-page interactions (e.g., `WorkflowComposer.executeWorkflowAndWaitForNotification()`)
- **config**: Test setup and configuration (constants, test users, etc.)
- **fixtures**: Custom test fixtures extending Playwright's base test
- **pages**: Page Object Models for UI interactions
- **services**: API helpers for E2E controller, REST calls, etc.
- **utils**: Utility functions (string manipulation, helpers, etc.)
- **workflows**: Test workflow JSON files for import/reuse

View File

@@ -0,0 +1,6 @@
export class TestError extends Error {
constructor(message: string) {
super(message);
this.name = 'TestError';
}
}

View File

@@ -0,0 +1,15 @@
import type { n8nPage } from '../pages/n8nPage';
export class CanvasComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Pin the data on a node. Then close the node.
* @param nodeName - The name of the node to pin the data on.
*/
async pinNodeData(nodeName: string) {
await this.n8n.canvas.openNode(nodeName);
await this.n8n.ndv.togglePinData();
await this.n8n.ndv.close();
}
}

View File

@@ -0,0 +1,56 @@
import type { n8nPage } from '../pages/n8nPage';
export class ProjectComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Create a project and return the project name and ID. If no project name is provided, a unique name will be generated.
* @param projectName - The name of the project to create.
* @returns The project name and ID.
*/
async createProject(projectName?: string) {
await this.n8n.page.getByTestId('universal-add').click();
await Promise.all([
this.n8n.page.waitForResponse('**/rest/projects/*'),
this.n8n.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click(),
]);
await this.n8n.notifications.waitForNotificationAndClose('saved successfully');
await this.n8n.page.waitForLoadState();
const projectNameUnique = projectName ?? `Project ${Date.now()}`;
await this.n8n.projectSettings.fillProjectName(projectNameUnique);
await this.n8n.projectSettings.clickSaveButton();
const projectId = await this.extractProjectIdFromPage('projects', 'settings');
return { projectName: projectNameUnique, projectId };
}
/**
* Add a new credential to a project.
* @param projectName - The name of the project to add the credential to.
* @param credentialType - The type of credential to add by visible name e.g 'Notion API'
* @param credentialFieldName - The name of the field to add the credential to. e.g. 'apiKey' which would be data-test-id='parameter-input-apiKey'
* @param credentialValue - The value of the credential to add.
*/
async addCredentialToProject(
projectName: string,
credentialType: string,
credentialFieldName: string,
credentialValue: string,
) {
await this.n8n.sideBar.openNewCredentialDialogForProject(projectName);
await this.n8n.credentials.openNewCredentialDialogFromCredentialList(credentialType);
await this.n8n.credentials.fillCredentialField(credentialFieldName, credentialValue);
await this.n8n.credentials.saveCredential();
await this.n8n.notifications.waitForNotificationAndClose('Credential successfully created');
await this.n8n.credentials.closeCredentialDialog();
}
extractIdFromUrl(url: string, beforeWord: string, afterWord: string): string {
const path = url.includes('://') ? new URL(url).pathname : url;
const match = path.match(new RegExp(`/${beforeWord}/([^/]+)/${afterWord}`));
return match?.[1] ?? '';
}
async extractProjectIdFromPage(beforeWord: string, afterWord: string): Promise<string> {
return this.extractIdFromUrl(this.n8n.page.url(), beforeWord, afterWord);
}
}

View File

@@ -0,0 +1,25 @@
import type { n8nPage } from '../pages/n8nPage';
/**
* A class for user interactions with workflows that go across multiple pages.
*/
export class WorkflowComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Executes a successful workflow and waits for the notification to be closed.
* This waits for http calls and also closes the notification.
*/
async executeWorkflowAndWaitForNotification(notificationMessage: string) {
const responsePromise = this.n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows/') &&
response.url().includes('/run') &&
response.request().method() === 'POST',
);
await this.n8n.canvas.clickExecuteWorkflowButton();
await responsePromise;
await this.n8n.notifications.waitForNotificationAndClose(notificationMessage);
}
}

View File

@@ -0,0 +1,43 @@
export const BACKEND_BASE_URL = 'http://localhost:5678';
export const N8N_AUTH_COOKIE = 'n8n-auth';
export const DEFAULT_USER_PASSWORD = 'PlaywrightTest123';
export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking Execute workflow';
export const MANUAL_CHAT_TRIGGER_NODE_NAME = 'Chat Trigger';
export const CHAT_TRIGGER_NODE_DISPLAY_NAME = 'When chat message received';
export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
export const CODE_NODE_NAME = 'Code';
export const SET_NODE_NAME = 'Set';
export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields';
export const LOOP_OVER_ITEMS_NODE_NAME = 'Loop Over Items';
export const IF_NODE_NAME = 'If';
export const MERGE_NODE_NAME = 'Merge';
export const SWITCH_NODE_NAME = 'Switch';
export const GMAIL_NODE_NAME = 'Gmail';
export const TRELLO_NODE_NAME = 'Trello';
export const NOTION_NODE_NAME = 'Notion';
export const PIPEDRIVE_NODE_NAME = 'Pipedrive';
export const HTTP_REQUEST_NODE_NAME = 'HTTP Request';
export const AGENT_NODE_NAME = 'AI Agent';
export const BASIC_LLM_CHAIN_NODE_NAME = 'Basic LLM Chain';
export const AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME = 'Simple Memory';
export const AI_TOOL_CALCULATOR_NODE_NAME = 'Calculator';
export const AI_TOOL_CODE_NODE_NAME = 'Code Tool';
export const AI_TOOL_WIKIPEDIA_NODE_NAME = 'Wikipedia';
export const AI_TOOL_HTTP_NODE_NAME = 'HTTP Request Tool';
export const AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME = 'OpenAI Chat Model';
export const AI_MEMORY_POSTGRES_NODE_NAME = 'Postgres Chat Memory';
export const AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME = 'Auto-fixing Output Parser';
export const WEBHOOK_NODE_NAME = 'Webhook';
export const EXECUTE_WORKFLOW_NODE_NAME = 'Execute Workflow';
export const NEW_GOOGLE_ACCOUNT_NAME = 'Gmail account';
export const NEW_TRELLO_ACCOUNT_NAME = 'Trello account';
export const NEW_NOTION_ACCOUNT_NAME = 'Notion account';
export const NEW_QUERY_AUTH_ACCOUNT_NAME = 'Query Auth account';
export const ROUTES = {
NEW_WORKFLOW_PAGE: '/workflow/new',
};

View File

@@ -0,0 +1,105 @@
/* eslint-disable import/no-extraneous-dependencies */
import type { FrontendSettings } from '@n8n/api-types';
import type { BrowserContext, Route } from '@playwright/test';
import cloneDeep from 'lodash/cloneDeep';
import merge from 'lodash/merge';
export let settings: Partial<FrontendSettings>;
export async function setupDefaultInterceptors(target: BrowserContext) {
await target.route('**/rest/settings', async (route: Route) => {
try {
const originalResponse = await route.fetch();
const originalJson = await originalResponse.json();
const modifiedData = {
data: merge(cloneDeep(originalJson.data), settings),
};
await route.fulfill({
status: originalResponse.status(),
headers: originalResponse.headers(),
contentType: 'application/json',
body: JSON.stringify(modifiedData),
});
} catch (error) {
console.error('Error in /rest/settings intercept:', error);
await route.continue();
}
});
// POST /rest/credentials/test
await target.route('**/rest/credentials/test', async (route: Route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ data: { status: 'success', message: 'Tested successfully' } }),
});
} else {
await route.continue();
}
});
// POST /rest/license/renew
await target.route('**/rest/license/renew', async (route: Route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
data: {
usage: { activeWorkflowTriggers: { limit: -1, value: 0, warningThreshold: 0.8 } },
license: { planId: '', planName: 'Community' },
},
}),
});
} else {
await route.continue();
}
});
// Pathname /api/health
await target.route(
(url) => url.pathname.endsWith('/api/health'),
async (route: Route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ status: 'OK' }),
});
},
);
// Pathname /api/versions/*
await target.route(
(url) => url.pathname.startsWith('/api/versions/'),
async (route: Route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
{
name: '1.45.1',
createdAt: '2023-08-18T11:53:12.857Z',
hasSecurityIssue: null,
hasSecurityFix: null,
securityIssueFixVersion: null,
hasBreakingChange: null,
documentationUrl: 'https://docs.n8n.io/release-notes/#n8n131',
nodes: [],
description: 'Includes <strong>bug fixes</strong>',
},
{
name: '1.0.5',
createdAt: '2023-07-24T10:54:56.097Z',
hasSecurityIssue: false,
hasSecurityFix: null,
securityIssueFixVersion: null,
hasBreakingChange: true,
documentationUrl: 'https://docs.n8n.io/release-notes/#n8n104',
nodes: [],
description:
'Includes <strong>core functionality</strong> and <strong>bug fixes</strong>',
},
]),
});
},
);
}

View File

@@ -0,0 +1,77 @@
import { DEFAULT_USER_PASSWORD } from './constants';
export interface UserCredentials {
email: string;
password: string;
firstName: string;
lastName: string;
}
// Simple name generators
const FIRST_NAMES = [
'Alex',
'Jordan',
'Taylor',
'Morgan',
'Casey',
'Riley',
'Avery',
'Quinn',
'Sam',
'Drew',
'Blake',
'Sage',
'River',
'Rowan',
'Skylar',
'Emery',
];
const LAST_NAMES = [
'Smith',
'Johnson',
'Williams',
'Brown',
'Jones',
'Garcia',
'Miller',
'Davis',
'Rodriguez',
'Martinez',
'Hernandez',
'Lopez',
'Gonzalez',
'Wilson',
'Anderson',
'Thomas',
];
const getRandomName = (names: string[]): string => {
return names[Math.floor(Math.random() * names.length)];
};
const randFirstName = (): string => getRandomName(FIRST_NAMES);
const randLastName = (): string => getRandomName(LAST_NAMES);
export const INSTANCE_OWNER_CREDENTIALS: UserCredentials = {
email: 'nathan@n8n.io',
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
};
export const INSTANCE_ADMIN_CREDENTIALS: UserCredentials = {
email: 'admin@n8n.io',
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
};
export const INSTANCE_MEMBER_CREDENTIALS: UserCredentials[] = [
{
email: 'member@n8n.io',
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
},
];

View File

@@ -0,0 +1,6 @@
import type { CurrentsConfig } from '@currents/playwright';
export const config: CurrentsConfig = {
recordKey: process.env.CURRENTS_RECORD_KEY ?? '',
projectId: process.env.CURRENTS_PROJECT_ID ?? 'I0yzoc',
};

View File

@@ -0,0 +1,149 @@
import { test as base, expect, type TestInfo } from '@playwright/test';
import type { N8NStack } from 'n8n-containers/n8n-test-container-creation';
import { createN8NStack } from 'n8n-containers/n8n-test-container-creation';
import { ContainerTestHelpers } from 'n8n-containers/n8n-test-container-helpers';
import { setupDefaultInterceptors } from '../config/intercepts';
import { n8nPage } from '../pages/n8nPage';
import { ApiHelpers } from '../services/api-helper';
import { TestError } from '../Types';
type TestFixtures = {
n8n: n8nPage;
api: ApiHelpers;
baseURL: string;
};
type WorkerFixtures = {
n8nUrl: string;
dbSetup: undefined;
chaos: ContainerTestHelpers;
n8nContainer: N8NStack;
containerConfig: ContainerConfig; // Configuration for container creation
};
interface ContainerConfig {
postgres?: boolean;
queueMode?: {
mains: number;
workers: number;
};
}
/**
* Extended Playwright test with n8n-specific fixtures.
* Supports both external n8n instances (via N8N_BASE_URL) and containerized testing.
* Provides tag-driven authentication and database management.
*/
export const test = base.extend<TestFixtures, WorkerFixtures>({
// Container configuration from the project use options
containerConfig: [
async ({}, use, testInfo: TestInfo) => {
const config = (testInfo.project.use?.containerConfig as ContainerConfig) || {};
await use(config);
},
{ scope: 'worker' },
],
// Create a new n8n container if N8N_BASE_URL is not set, otherwise use the existing n8n instance
n8nContainer: [
async ({ containerConfig }, use) => {
const envBaseURL = process.env.N8N_BASE_URL;
if (envBaseURL) {
console.log(`Using external N8N_BASE_URL: ${envBaseURL}`);
await use(null as unknown as N8NStack);
return;
}
console.log('Creating container with config:', containerConfig);
const container = await createN8NStack(containerConfig);
// TODO: Remove this once we have a better way to wait for the container to be ready (e.g. healthcheck)
await new Promise((resolve) => setTimeout(resolve, 5000));
console.log(`Container URL: ${container.baseUrl}`);
await use(container);
await container.stop();
},
{ scope: 'worker' },
],
// Set the n8n URL for based on the N8N_BASE_URL environment variable or the n8n container
n8nUrl: [
async ({ n8nContainer }, use) => {
const envBaseURL = process.env.N8N_BASE_URL ?? n8nContainer?.baseUrl;
await use(envBaseURL);
},
{ scope: 'worker' },
],
// Reset the database for the new container
dbSetup: [
async ({ n8nUrl, n8nContainer, browser }, use) => {
if (n8nContainer) {
console.log('Resetting database for new container');
const context = await browser.newContext({ baseURL: n8nUrl });
const api = new ApiHelpers(context.request);
await api.resetDatabase();
await context.close();
}
await use(undefined);
},
{ scope: 'worker' },
],
// Create container test helpers for the n8n container.
chaos: [
async ({ n8nContainer }, use) => {
if (process.env.N8N_BASE_URL) {
throw new TestError(
'Chaos testing is not supported when using N8N_BASE_URL environment variable. Remove N8N_BASE_URL to use containerized testing.',
);
}
const helpers = new ContainerTestHelpers(n8nContainer.containers);
await use(helpers);
},
{ scope: 'worker' },
],
baseURL: async ({ n8nUrl }, use) => {
await use(n8nUrl);
},
// Browser, baseURL, and dbSetup are required here to ensure they run first.
// This is how Playwright does dependency graphs
context: async ({ context, browser, baseURL, dbSetup }, use) => {
await setupDefaultInterceptors(context);
await use(context);
},
page: async ({ context }, use, testInfo) => {
const page = await context.newPage();
const api = new ApiHelpers(context.request);
await api.setupFromTags(testInfo.tags);
await use(page);
await page.close();
},
n8n: async ({ page }, use) => {
const n8nInstance = new n8nPage(page);
await use(n8nInstance);
},
api: async ({ context }, use) => {
const api = new ApiHelpers(context.request);
await use(api);
},
});
export { expect };
/*
Dependency Graph:
Worker Scope: containerConfig → n8nContainer → [n8nUrl, chaos] → dbSetup
Test Scope: n8nUrl → baseURL → context → page → [n8n, api]
*/

View File

@@ -0,0 +1,43 @@
import { request } from '@playwright/test';
import { ApiHelpers } from './services/api-helper';
async function globalSetup() {
console.log('🚀 Starting global setup...');
// Check if N8N_BASE_URL is set
const n8nBaseUrl = process.env.N8N_BASE_URL;
if (!n8nBaseUrl) {
console.log('⚠️ N8N_BASE_URL environment variable is not set, skipping database reset');
return;
}
const resetE2eDb = process.env.RESET_E2E_DB;
if (resetE2eDb !== 'true') {
console.log('⚠️ RESET_E2E_DB is not set to "true", skipping database reset');
return;
}
console.log(`🔄 Resetting database for ${n8nBaseUrl}...`);
// Create standalone API request context
const requestContext = await request.newContext({
baseURL: n8nBaseUrl,
});
try {
const api = new ApiHelpers(requestContext);
await api.resetDatabase();
console.log('✅ Database reset completed successfully');
} catch (error) {
console.error('❌ Failed to reset database:', error);
throw error; // This will fail the entire test suite if database reset fails
} finally {
await requestContext.dispose();
}
console.log('🏁 Global setup completed');
}
// eslint-disable-next-line import/no-default-export
export default globalSetup;

View File

@@ -0,0 +1,23 @@
{
"name": "n8n-playwright",
"private": true,
"scripts": {
"test": "playwright test",
"test:local:reset": "N8N_BASE_URL=http://localhost:5678 RESET_E2E_DB=true playwright test --workers=4",
"test:local": "N8N_BASE_URL=http://localhost:5678 playwright test",
"test:standard": "playwright test --project=mode:standard*",
"test:postgres": "playwright test --project=mode:postgres*",
"test:queue": "playwright test --project=mode:queue*",
"test:multi-main": "playwright test --project=mode:multi-main*",
"test:clean": "docker rm -f $(docker ps -aq --filter 'name=n8n-*') 2>/dev/null || true && docker network prune -f",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"install-browsers": "PLAYWRIGHT_BROWSERS_PATH=./ms-playwright-cache playwright install chromium --with-deps"
},
"devDependencies": {
"@currents/playwright": "1.14.1",
"@playwright/test": "1.53.0",
"eslint-plugin-playwright": "2.2.0",
"n8n-containers": "workspace:*"
}
}

View File

@@ -0,0 +1,21 @@
import type { Page } from '@playwright/test';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
protected async clickByTestId(testId: string) {
await this.page.getByTestId(testId).click();
}
protected async fillByTestId(testId: string, value: string) {
await this.page.getByTestId(testId).fill(value);
}
protected async clickByText(text: string) {
await this.page.getByText(text).click();
}
protected async clickButtonByName(name: string) {
await this.page.getByRole('button', { name }).click();
}
}

View File

@@ -0,0 +1,117 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class CanvasPage extends BasePage {
saveWorkflowButton(): Locator {
return this.page.getByRole('button', { name: 'Save' });
}
nodeCreatorItemByName(text: string): Locator {
return this.page.getByTestId('node-creator-item-name').getByText(text, { exact: true });
}
nodeCreatorSubItem(subItemText: string): Locator {
return this.page.getByTestId('node-creator-item-name').getByText(subItemText, { exact: true });
}
nodeByName(nodeName: string): Locator {
return this.page.locator(`[data-test-id="canvas-node"][data-node-name="${nodeName}"]`);
}
nodeToolbar(nodeName: string): Locator {
return this.nodeByName(nodeName).getByTestId('canvas-node-toolbar');
}
nodeDeleteButton(nodeName: string): Locator {
return this.nodeToolbar(nodeName).getByTestId('delete-node-button');
}
async clickCanvasPlusButton(): Promise<void> {
await this.clickByTestId('canvas-plus-button');
}
async clickNodeCreatorPlusButton(): Promise<void> {
await this.clickByTestId('node-creator-plus-button');
}
async clickSaveWorkflowButton(): Promise<void> {
await this.clickButtonByName('Save');
}
async fillNodeCreatorSearchBar(text: string): Promise<void> {
await this.fillByTestId('node-creator-search-bar', text);
}
async clickNodeCreatorItemName(text: string): Promise<void> {
await this.nodeCreatorItemByName(text).click();
}
async addNode(text: string): Promise<void> {
await this.clickNodeCreatorPlusButton();
await this.fillNodeCreatorSearchBar(text);
await this.clickNodeCreatorItemName(text);
}
async addNodeToCanvasWithSubItem(searchText: string, subItemText: string): Promise<void> {
await this.addNode(searchText);
await this.nodeCreatorSubItem(subItemText).click();
}
async deleteNodeByName(nodeName: string): Promise<void> {
await this.nodeDeleteButton(nodeName).click();
}
async saveWorkflow(): Promise<void> {
await this.clickSaveWorkflowButton();
}
async clickExecuteWorkflowButton(): Promise<void> {
await this.page.getByTestId('execute-workflow-button').click();
}
async clickDebugInEditorButton(): Promise<void> {
await this.page.getByRole('button', { name: 'Debug in editor' }).click();
}
async pinNodeByNameUsingContextMenu(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByTestId('context-menu').getByText('Pin').click();
}
async unpinNodeByNameUsingContextMenu(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByText('Unpin').click();
}
async openNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).dblclick();
}
/**
* Get the names of all pinned nodes on the canvas.
* @returns An array of node names.
*/
async getPinnedNodeNames(): Promise<string[]> {
const pinnedNodesLocator = this.page
.getByTestId('canvas-node')
.filter({ has: this.page.getByTestId('canvas-node-status-pinned') });
const names: string[] = [];
const count = await pinnedNodesLocator.count();
for (let i = 0; i < count; i++) {
const node = pinnedNodesLocator.nth(i);
const name = await node.getAttribute('data-node-name');
if (name) {
names.push(name);
}
}
return names;
}
async clickExecutionsTab(): Promise<void> {
await this.page.getByRole('radio', { name: 'Executions' }).click();
}
}

View File

@@ -0,0 +1,65 @@
import { BasePage } from './BasePage';
export class CredentialsPage extends BasePage {
get emptyListCreateCredentialButton() {
return this.page.getByRole('button', { name: 'Add first credential' });
}
get createCredentialButton() {
return this.page.getByTestId('create-credential-button');
}
get credentialCards() {
return this.page.getByTestId('credential-cards');
}
/**
* Create a new credential of the specified type
* @param credentialType - The type of credential to create (e.g. 'Notion API')
*/
async openNewCredentialDialogFromCredentialList(credentialType: string): Promise<void> {
await this.page.getByRole('combobox', { name: 'Search for app...' }).fill(credentialType);
await this.page
.getByTestId('new-credential-type-select-option')
.filter({ hasText: credentialType })
.click();
await this.page.getByTestId('new-credential-type-button').click();
}
async openCredentialSelector() {
await this.page.getByRole('combobox', { name: 'Select Credential' }).click();
}
async createNewCredential() {
await this.clickByText('Create new credential');
}
async fillCredentialField(fieldName: string, value: string) {
const field = this.page
.getByTestId(`parameter-input-${fieldName}`)
.getByTestId('parameter-input-field');
await field.click();
await field.fill(value);
}
async saveCredential() {
await this.clickButtonByName('Save');
}
async closeCredentialDialog() {
await this.clickButtonByName('Close this dialog');
}
async createAndSaveNewCredential(fieldName: string, value: string) {
await this.openCredentialSelector();
await this.createNewCredential();
await this.filLCredentialSaveClose(fieldName, value);
}
async filLCredentialSaveClose(fieldName: string, value: string) {
await this.fillCredentialField(fieldName, value);
await this.saveCredential();
await this.page.getByText('Connection tested successfully').waitFor({ state: 'visible' });
await this.closeCredentialDialog();
}
}

View File

@@ -0,0 +1,36 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class ExecutionsPage extends BasePage {
async clickDebugInEditorButton(): Promise<void> {
await this.clickButtonByName('Debug in editor');
}
async clickCopyToEditorButton(): Promise<void> {
await this.clickButtonByName('Copy to editor');
}
async getExecutionItems(): Promise<Locator> {
return this.page.locator('div.execution-card');
}
async getLastExecutionItem(): Promise<Locator> {
const executionItems = await this.getExecutionItems();
return executionItems.nth(0);
}
async clickLastExecutionItem(): Promise<void> {
const executionItem = await this.getLastExecutionItem();
await executionItem.click();
}
/**
* Handle the pinned nodes confirmation dialog.
* @param action - The action to take.
*/
async handlePinnedNodesConfirmation(action: 'Unpin' | 'Cancel'): Promise<void> {
const confirmDialog = this.page.locator('.matching-pinned-nodes-confirmation');
await this.page.getByRole('button', { name: action }).click();
}
}

View File

@@ -0,0 +1,38 @@
import { BasePage } from './BasePage';
export class NodeDisplayViewPage extends BasePage {
async clickBackToCanvasButton() {
await this.clickByTestId('back-to-canvas');
}
getParameterByLabel(labelName: string) {
return this.page.locator('.parameter-item').filter({ hasText: labelName });
}
/**
* Fill a parameter input field
* @param labelName - The label of the parameter e.g URL
* @param value - The value to fill in the input field e.g https://foo.bar
*/
async fillParameterInput(labelName: string, value: string) {
await this.getParameterByLabel(labelName).getByTestId('parameter-input-field').fill(value);
}
async selectWorkflowResource(createItemText: string, searchText: string = '') {
await this.clickByTestId('rlc-input');
if (searchText) {
await this.fillByTestId('rlc-search', searchText);
}
await this.clickByText(createItemText);
}
async togglePinData() {
await this.clickByTestId('ndv-pin-data');
}
async close() {
await this.clickBackToCanvasButton();
}
}

View File

@@ -0,0 +1,234 @@
import type { Locator, Page } from '@playwright/test';
export class NotificationsPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
/**
* Gets the main container locator for a notification by its visible text.
* @param text The text or a regular expression to find within the notification's title.
* @returns A Locator for the notification container element.
*/
notificationContainerByText(text: string | RegExp): Locator {
return this.page.getByRole('alert').filter({
has: this.page.locator('.el-notification__title').filter({ hasText: text }),
});
}
/**
* Clicks the close button on the FIRST notification matching the text.
* Fast execution with short timeouts for snappy notifications.
* @param text The text of the notification to close.
* @param options Optional configuration
*/
async closeNotificationByText(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 2000 } = options;
try {
const notification = this.notificationContainerByText(text).first();
await notification.waitFor({ state: 'visible', timeout });
const closeBtn = notification.locator('.el-notification__closeBtn');
await closeBtn.click({ timeout: 500 });
// Quick check that it's gone - don't wait long
await notification.waitFor({ state: 'hidden', timeout: 1000 });
return true;
} catch (error) {
return false;
}
}
/**
* Closes ALL currently visible notifications that match the given text.
* Uses aggressive polling for fast cleanup.
* @param text The text of the notifications to close.
* @param options Optional configuration
*/
async closeAllNotificationsWithText(
text: string | RegExp,
options: { timeout?: number; maxRetries?: number } = {},
): Promise<number> {
const { timeout = 1500, maxRetries = 15 } = options;
let closedCount = 0;
let retries = 0;
while (retries < maxRetries) {
try {
const notifications = this.notificationContainerByText(text);
const count = await notifications.count();
if (count === 0) {
break;
}
// Close the first visible notification quickly
const firstNotification = notifications.first();
if (await firstNotification.isVisible({ timeout: 200 })) {
const closeBtn = firstNotification.locator('.el-notification__closeBtn');
await closeBtn.click({ timeout: 300 });
// Brief wait for disappearance, then continue
await firstNotification.waitFor({ state: 'hidden', timeout: 500 }).catch(() => {});
closedCount++;
} else {
// If not visible, likely already gone
break;
}
} catch (error) {
// Continue quickly on any error
break;
}
retries++;
}
return closedCount;
}
/**
* Check if a notification is visible based on text.
* Fast check with short timeout.
* @param text The text to search for in notification title.
* @param options Optional configuration
*/
async isNotificationVisible(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 500 } = options;
try {
const notification = this.notificationContainerByText(text).first();
await notification.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
/**
* Wait for a notification to appear with specific text.
* Reasonable timeout for waiting, but still faster than before.
* @param text The text to search for in notification title.
* @param options Optional configuration
*/
async waitForNotification(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 5000 } = options;
try {
const notification = this.notificationContainerByText(text).first();
await notification.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
// Wait for notification and then close it
async waitForNotificationAndClose(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 3000 } = options;
await this.waitForNotification(text, { timeout });
await this.closeNotificationByText(text, { timeout });
return true;
}
/**
* Get all visible notification texts.
* @returns Array of notification title texts
*/
async getAllNotificationTexts(): Promise<string[]> {
try {
const titles = this.page.getByRole('alert').locator('.el-notification__title');
return await titles.allTextContents();
} catch {
return [];
}
}
/**
* Wait for all notifications to disappear.
* Fast check with short timeout.
* @param options Optional configuration
*/
async waitForAllNotificationsToDisappear(options: { timeout?: number } = {}): Promise<boolean> {
const { timeout = 2000 } = options;
try {
// Wait for no alerts to be visible
await this.page.getByRole('alert').first().waitFor({
state: 'detached',
timeout,
});
return true;
} catch {
// Check if any are still visible
const count = await this.getNotificationCount();
return count === 0;
}
}
/**
* Get the count of visible notifications.
* @param text Optional text to filter notifications
*/
async getNotificationCount(text?: string | RegExp): Promise<number> {
try {
const notifications = text
? this.notificationContainerByText(text)
: this.page.getByRole('alert');
return await notifications.count();
} catch {
return 0;
}
}
/**
* Quick utility to close any notification and continue.
* Uses the most aggressive timeouts for maximum speed.
* @param text The text of the notification to close.
*/
async quickClose(text: string | RegExp): Promise<void> {
try {
const notification = this.notificationContainerByText(text).first();
if (await notification.isVisible({ timeout: 100 })) {
await notification.locator('.el-notification__closeBtn').click({ timeout: 200 });
}
} catch {
// Silent fail for speed
}
}
/**
* Nuclear option: Close everything as fast as possible.
* No waiting, no error handling, just close and move on.
*/
async quickCloseAll(): Promise<void> {
try {
const closeButtons = this.page.locator('.el-notification__closeBtn');
const count = await closeButtons.count();
for (let i = 0; i < count; i++) {
try {
await closeButtons.nth(i).click({ timeout: 100 });
} catch {
// Continue silently
}
}
} catch {
// Silent fail
}
}
}

View File

@@ -0,0 +1,11 @@
import { BasePage } from './BasePage';
export class ProjectSettingsPage extends BasePage {
async fillProjectName(name: string) {
await this.page.getByTestId('project-settings-name-input').locator('input').fill(name);
}
async clickSaveButton() {
await this.clickButtonByName('Save');
}
}

View File

@@ -0,0 +1,11 @@
import { BasePage } from './BasePage';
export class ProjectWorkflowsPage extends BasePage {
async clickCreateWorkflowButton() {
await this.clickByTestId('add-resource-workflow');
}
async clickProjectMenuItem(projectName: string) {
await this.page.getByTestId('project-menu-item').filter({ hasText: projectName }).click();
}
}

View File

@@ -0,0 +1,42 @@
import type { Locator, Page } from '@playwright/test';
export class SidebarPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async clickAddProjectButton() {
await this.page.getByTestId('project-plus-button').click();
}
async universalAdd() {
await this.page.getByTestId('universal-add').click();
}
async addProjectFromUniversalAdd() {
await this.universalAdd();
await this.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click();
}
async addWorkflowFromUniversalAdd(projectName: string) {
await this.universalAdd();
await this.page.getByTestId('universal-add').getByText('Workflow').click();
await this.page.getByTestId('universal-add').getByRole('link', { name: projectName }).click();
}
async openNewCredentialDialogForProject(projectName: string) {
await this.universalAdd();
await this.page.getByTestId('universal-add').getByText('Credential').click();
await this.page.getByTestId('universal-add').getByRole('link', { name: projectName }).click();
}
getProjectMenuItems(): Locator {
return this.page.getByTestId('project-menu-item');
}
getAddFirstProjectButton(): Locator {
return this.page.getByTestId('add-first-project-button');
}
}

View File

@@ -0,0 +1,45 @@
import { BasePage } from './BasePage';
import { resolveFromRoot } from '../utils/path-helper';
export class WorkflowsPage extends BasePage {
async clickNewWorkflowCard() {
await this.clickByTestId('new-workflow-card');
}
async clickAddFirstProjectButton() {
await this.clickByTestId('add-first-project-button');
}
async clickAddProjectButton() {
await this.clickByTestId('project-plus-button');
}
async clickAddWorklowButton() {
await this.clickByTestId('add-resource-workflow');
}
/**
* Import a workflow from a fixture file
* @param fixtureKey - The key of the fixture file to import
* @param workflowName - The name of the workflow to import
* Naming the file causes the workflow to save so we don't need to click save
*/
async importWorkflow(fixtureKey: string, workflowName: string) {
await this.clickByTestId('workflow-menu');
const [fileChooser] = await Promise.all([
this.page.waitForEvent('filechooser'),
this.clickByText('Import from File...'),
]);
await fileChooser.setFiles(resolveFromRoot('workflows', fixtureKey));
await this.page.waitForTimeout(250);
await this.clickByTestId('inline-edit-preview');
await this.fillByTestId('inline-edit-input', workflowName);
await this.page.getByTestId('inline-edit-input').press('Enter');
}
workflowTags() {
return this.page.getByTestId('workflow-tags').locator('.el-tag');
}
}

View File

@@ -0,0 +1,68 @@
import type { Page } from '@playwright/test';
import { CanvasPage } from './CanvasPage';
import { CredentialsPage } from './CredentialsPage';
import { ExecutionsPage } from './ExecutionsPage';
import { NodeDisplayViewPage } from './NodeDisplayViewPage';
import { NotificationsPage } from './NotificationsPage';
import { ProjectSettingsPage } from './ProjectSettingsPage';
import { ProjectWorkflowsPage } from './ProjectWorkflowsPage';
import { SidebarPage } from './SidebarPage';
import { WorkflowsPage } from './WorkflowsPage';
import { CanvasComposer } from '../composables/CanvasComposer';
import { ProjectComposer } from '../composables/ProjectComposer';
import { WorkflowComposer } from '../composables/WorkflowComposer';
export class n8nPage {
readonly page: Page;
// Pages
readonly canvas: CanvasPage;
readonly ndv: NodeDisplayViewPage;
readonly projectWorkflows: ProjectWorkflowsPage;
readonly projectSettings: ProjectSettingsPage;
readonly workflows: WorkflowsPage;
readonly notifications: NotificationsPage;
readonly credentials: CredentialsPage;
readonly executions: ExecutionsPage;
readonly sideBar: SidebarPage;
// Composables
readonly workflowComposer: WorkflowComposer;
readonly projectComposer: ProjectComposer;
readonly canvasComposer: CanvasComposer;
constructor(page: Page) {
this.page = page;
// Pages
this.canvas = new CanvasPage(page);
this.ndv = new NodeDisplayViewPage(page);
this.projectWorkflows = new ProjectWorkflowsPage(page);
this.projectSettings = new ProjectSettingsPage(page);
this.workflows = new WorkflowsPage(page);
this.notifications = new NotificationsPage(page);
this.credentials = new CredentialsPage(page);
this.executions = new ExecutionsPage(page);
this.sideBar = new SidebarPage(page);
// Composables
this.workflowComposer = new WorkflowComposer(this);
this.projectComposer = new ProjectComposer(this);
this.canvasComposer = new CanvasComposer(this);
}
async goHome() {
await this.page.goto('/');
}
}

View File

@@ -0,0 +1,127 @@
/* eslint-disable import/no-default-export */
import type { Project } from '@playwright/test';
import { defineConfig } from '@playwright/test';
/*
* Mode-based Test Configuration
*
* Usage examples:
*
* 1. Run only mode:standard tests:
* npx playwright test --project="mode:standard*"
*
* 2. Run only parallel tests for all modes:
* npx playwright test --project="*Parallel"
*
* 3. Run a specific mode's sequential tests:
* npx playwright test --project="mode:multi-main - Sequential"
*
* Test tagging examples:
*
* // Runs on all modes
* test('basic functionality', async ({ page }) => { ... });
*
* // Only runs on multi-main mode
* test('multi-main specific @mode:multi-main', async ({ page }) => { ... });
*
* // Only runs on postgres mode, and in sequential execution
* test('database reset test @mode:postgres @db:reset', async ({ page }) => { ... });
*
* // Runs on all modes, but in sequential execution
* test('another reset test @db:reset', async ({ page }) => { ... });
*/
// Container configurations
const containerConfigs = [
{ name: 'mode:standard', config: {} },
{ name: 'mode:postgres', config: { postgres: true } },
{ name: 'mode:queue', config: { queueMode: { mains: 1, workers: 1 } } },
{ name: 'mode:multi-main', config: { queueMode: { mains: 2, workers: 1 } } },
];
// Parallel tests can run fully parallel on a worker
// Sequential tests can run on a single worker, since the need a DB reset
// Chaos tests can run on a single worker, since they can destroy containers etc, these need to be isolate from DB tests since they are destructive
function createProjectTrio(name: string, containerConfig: any): Project[] {
const modeTag = `@${name}`;
// Parse custom env vars from command line
const customEnv = process.env.N8N_TEST_ENV ? JSON.parse(process.env.N8N_TEST_ENV) : {};
// Merge custom env vars into container config
const mergedConfig = {
...containerConfig,
env: {
...containerConfig.env,
...customEnv,
},
};
// Only add dependencies when using external URL (i.e., using containers)
// This is to stop DB reset tests from running in parallel with other tests when more than 1 worker is used
const shouldAddDependencies = process.env.N8N_BASE_URL;
return [
{
name: `${name} - Parallel`,
grep: new RegExp(
`${modeTag}(?!.*(@db:reset|@chaostest))|^(?!.*(@mode:|@db:reset|@chaostest))`,
),
fullyParallel: true,
use: { containerConfig: mergedConfig } as any,
},
{
name: `${name} - Sequential`,
grep: new RegExp(`${modeTag}.*@db:reset|@db:reset(?!.*@mode:)`),
fullyParallel: false,
workers: 1,
...(shouldAddDependencies && { dependencies: [`${name} - Parallel`] }),
use: { containerConfig: mergedConfig } as any,
},
{
name: `${name} - Chaos`,
grep: new RegExp(`${modeTag}.*@chaostest`),
fullyParallel: false,
workers: 1,
use: { containerConfig: mergedConfig } as any,
timeout: 120000,
},
];
}
export default defineConfig({
globalSetup: './global-setup.ts',
testDir: './tests',
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : 8,
timeout: 60000,
reporter: process.env.CI
? [
['list'],
['github'],
['junit', { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT_NAME ?? 'results.xml' }],
['html', { open: 'never' }],
['json', { outputFile: 'test-results.json' }],
['blob'],
]
: [['html']],
use: {
trace: 'on',
video: 'on',
screenshot: 'on',
testIdAttribute: 'data-test-id',
headless: true,
viewport: { width: 1536, height: 960 },
actionTimeout: 10000,
navigationTimeout: 10000,
},
projects: process.env.N8N_BASE_URL
? containerConfigs
.filter(({ name }) => name === 'mode:standard')
.flatMap(({ name, config }) => createProjectTrio(name, config))
: containerConfigs.flatMap(({ name, config }) => createProjectTrio(name, config)),
});

View File

@@ -0,0 +1,238 @@
// services/api-helper.ts
import type { APIRequestContext } from '@playwright/test';
import type { UserCredentials } from '../config/test-users';
import {
INSTANCE_OWNER_CREDENTIALS,
INSTANCE_MEMBER_CREDENTIALS,
INSTANCE_ADMIN_CREDENTIALS,
} from '../config/test-users';
import { TestError } from '../Types';
export interface LoginResponseData {
id: string;
[key: string]: any;
}
export type UserRole = 'owner' | 'admin' | 'member';
export type TestState = 'fresh' | 'reset' | 'signin-only';
const AUTH_TAGS = {
ADMIN: '@auth:admin',
OWNER: '@auth:owner',
MEMBER: '@auth:member',
NONE: '@auth:none',
} as const;
const DB_TAGS = {
RESET: '@db:reset',
} as const;
export class ApiHelpers {
private request: APIRequestContext;
constructor(requestContext: APIRequestContext) {
this.request = requestContext;
}
// ===== MAIN SETUP METHODS =====
/**
* Setup test environment based on test tags (recommended approach)
* @param tags - Array of test tags (e.g., ['@db:reset', '@auth:owner'])
* @param memberIndex - Which member to use (if auth role is 'member')
*
* Examples:
* - ['@db:reset'] = reset DB, manual signin required
* - ['@db:reset', '@auth:owner'] = reset DB + signin as owner
* - ['@auth:admin'] = signin as admin (no reset)
*/
async setupFromTags(tags: string[], memberIndex: number = 0): Promise<LoginResponseData | null> {
const shouldReset = this.shouldResetDatabase(tags);
const role = this.getRoleFromTags(tags);
if (shouldReset && role) {
// Reset + signin
await this.resetDatabase();
return await this.signin(role, memberIndex);
} else if (shouldReset) {
// Reset only, manual signin required
await this.resetDatabase();
return null;
} else if (role) {
// Signin only
return await this.signin(role, memberIndex);
}
// No setup required
return null;
}
/**
* Setup test environment based on desired state (programmatic approach)
* @param state - 'fresh': new container, 'reset': reset DB + signin, 'signin-only': just signin
* @param role - User role to sign in as
* @param memberIndex - Which member to use (if role is 'member')
*/
async setupTest(
state: TestState,
role: UserRole = 'owner',
memberIndex: number = 0,
): Promise<LoginResponseData | null> {
switch (state) {
case 'fresh':
// For fresh docker container - just reset, no signin needed yet
await this.resetDatabase();
return null;
case 'reset':
// Reset database then sign in
await this.resetDatabase();
return await this.signin(role, memberIndex);
case 'signin-only':
// Just sign in without reset
return await this.signin(role, memberIndex);
default:
throw new TestError('Unknown test state');
}
}
// ===== CORE METHODS =====
async resetDatabase(): Promise<void> {
const response = await this.request.post('/rest/e2e/reset', {
data: {
owner: INSTANCE_OWNER_CREDENTIALS,
members: INSTANCE_MEMBER_CREDENTIALS,
admin: INSTANCE_ADMIN_CREDENTIALS,
},
});
if (!response.ok()) {
const errorText = await response.text();
throw new TestError(errorText);
}
// Adding small delay to ensure database is reset
await new Promise((resolve) => setTimeout(resolve, 1000));
}
async signin(role: UserRole, memberIndex: number = 0): Promise<LoginResponseData> {
const credentials = this.getCredentials(role, memberIndex);
return await this.loginAndSetCookies(credentials);
}
// ===== CONFIGURATION METHODS =====
async setFeature(feature: string, enabled: boolean): Promise<void> {
await this.request.patch('/rest/e2e/feature', {
data: { feature: `feat:${feature}`, enabled },
});
}
async setQuota(quotaName: string, value: number | string): Promise<void> {
await this.request.patch('/rest/e2e/quota', {
data: { feature: `quota:${quotaName}`, value },
});
}
async setQueueMode(enabled: boolean): Promise<void> {
await this.request.patch('/rest/e2e/queue-mode', {
data: { enabled },
});
}
// ===== CONVENIENCE METHODS =====
async enableFeature(feature: string): Promise<void> {
await this.setFeature(feature, true);
}
async disableFeature(feature: string): Promise<void> {
await this.setFeature(feature, false);
}
async setMaxTeamProjectsQuota(value: number | string): Promise<void> {
await this.setQuota('maxTeamProjects', value);
}
async get(path: string, params?: URLSearchParams) {
const response = await this.request.get(path, { params });
const { data } = await response.json();
return data;
}
// ===== PRIVATE METHODS =====
private async loginAndSetCookies(
credentials: Pick<UserCredentials, 'email' | 'password'>,
): Promise<LoginResponseData> {
const response = await this.request.post('/rest/login', {
data: {
emailOrLdapLoginId: credentials.email,
password: credentials.password,
},
});
if (!response.ok()) {
const errorText = await response.text();
throw new TestError(errorText);
}
let responseData: any;
try {
responseData = await response.json();
} catch (error) {
const errorText = await response.text();
throw new TestError(errorText);
}
const loginData: LoginResponseData = responseData.data;
if (!loginData?.id) {
throw new TestError('Login did not return expected user data (missing user ID)');
}
return loginData;
}
private getCredentials(role: UserRole, memberIndex: number = 0): UserCredentials {
switch (role) {
case 'owner':
return INSTANCE_OWNER_CREDENTIALS;
case 'admin':
return INSTANCE_ADMIN_CREDENTIALS;
case 'member':
if (!INSTANCE_MEMBER_CREDENTIALS || memberIndex >= INSTANCE_MEMBER_CREDENTIALS.length) {
throw new TestError(`No member credentials found for index ${memberIndex}`);
}
return INSTANCE_MEMBER_CREDENTIALS[memberIndex];
default:
throw new TestError(`Unknown role: ${role as string}`);
}
}
// ===== TAG PARSING METHODS =====
private shouldResetDatabase(tags: string[]): boolean {
const lowerTags = tags.map((tag) => tag.toLowerCase());
return lowerTags.includes(DB_TAGS.RESET.toLowerCase());
}
/**
* Get the role from the tags
* @param tags - Array of test tags (e.g., ['@db:reset', '@auth:owner'])
* @returns The role from the tags, or 'owner' if no role is found
*/
getRoleFromTags(tags: string[]): UserRole | null {
const lowerTags = tags.map((tag) => tag.toLowerCase());
if (lowerTags.includes(AUTH_TAGS.ADMIN.toLowerCase())) return 'admin';
if (lowerTags.includes(AUTH_TAGS.OWNER.toLowerCase())) return 'owner';
if (lowerTags.includes(AUTH_TAGS.MEMBER.toLowerCase())) return 'member';
if (lowerTags.includes(AUTH_TAGS.NONE.toLowerCase())) return null;
return 'owner';
}
}

View File

@@ -0,0 +1,11 @@
import { test, expect } from '../fixtures/base';
// Example of importing a workflow from a file
test.describe('Workflows', () => {
test('should create a new workflow using empty state card @db:reset', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.clickNewWorkflowCard();
await n8n.workflows.importWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
await expect(n8n.workflows.workflowTags()).toHaveText(['some-tag-1', 'some-tag-2']);
});
});

View File

@@ -0,0 +1,113 @@
import { test, expect } from '../fixtures/base';
// Example of using helper functions inside a test
test.describe('Debug mode', () => {
// Constants to avoid magic strings
const URLS = {
FAILING: 'https://foo.bar',
SUCCESS: 'https://postman-echo.com/get?foo1=bar1&foo2=bar2',
};
const NOTIFICATIONS = {
WORKFLOW_CREATED: 'Workflow successfully created',
EXECUTION_IMPORTED: 'Execution data imported',
PROBLEM_IN_NODE: 'Problem in node',
SUCCESSFUL: 'Successful',
DATA_NOT_IMPORTED: "Some execution data wasn't imported",
};
test.beforeEach(async ({ api, n8n }) => {
await api.enableFeature('debugInEditor');
await n8n.goHome();
});
// Helper function to create basic workflow
async function createBasicWorkflow(n8n, url = URLS.FAILING) {
await n8n.workflows.clickAddWorklowButton();
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request');
await n8n.ndv.fillParameterInput('URL', url);
await n8n.ndv.close();
await n8n.canvas.clickSaveWorkflowButton();
await n8n.notifications.waitForNotificationAndClose(NOTIFICATIONS.WORKFLOW_CREATED);
}
// Helper function to import execution for debugging
async function importExecutionForDebugging(n8n) {
await n8n.canvas.clickExecutionsTab();
await n8n.executions.clickDebugInEditorButton();
await n8n.notifications.waitForNotificationAndClose(NOTIFICATIONS.EXECUTION_IMPORTED);
}
test('should enter debug mode for failed executions', async ({ n8n }) => {
await createBasicWorkflow(n8n, URLS.FAILING);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.PROBLEM_IN_NODE);
await importExecutionForDebugging(n8n);
expect(n8n.page.url()).toContain('/debug');
});
test('should exit debug mode after successful execution', async ({ n8n }) => {
await createBasicWorkflow(n8n, URLS.FAILING);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.PROBLEM_IN_NODE);
await importExecutionForDebugging(n8n);
await n8n.canvas.openNode('HTTP Request');
await n8n.ndv.fillParameterInput('URL', URLS.SUCCESS);
await n8n.ndv.close();
await n8n.canvas.clickSaveWorkflowButton();
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.SUCCESSFUL);
expect(n8n.page.url()).not.toContain('/debug');
});
test('should handle pinned data conflicts during execution import', async ({ n8n }) => {
await createBasicWorkflow(n8n, URLS.SUCCESS);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.SUCCESSFUL);
await n8n.canvasComposer.pinNodeData('HTTP Request');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification('Successful');
// Go to executions and try to copy execution to editor
await n8n.canvas.clickExecutionsTab();
await n8n.executions.clickLastExecutionItem();
await n8n.executions.clickCopyToEditorButton();
// Test CANCEL dialog
await n8n.executions.handlePinnedNodesConfirmation('Cancel');
// Try again and CONFIRM
await n8n.executions.clickLastExecutionItem();
await n8n.executions.clickCopyToEditorButton();
await n8n.executions.handlePinnedNodesConfirmation('Unpin');
expect(n8n.page.url()).toContain('/debug');
// Verify pinned status
const pinnedNodeNames = await n8n.canvas.getPinnedNodeNames();
expect(pinnedNodeNames).not.toContain('HTTP Request');
expect(pinnedNodeNames).toContain('When clicking Execute workflow');
});
test('should show error for pinned data mismatch', async ({ n8n }) => {
// Create workflow, execute, and pin data
await createBasicWorkflow(n8n, URLS.SUCCESS);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.SUCCESSFUL);
await n8n.canvasComposer.pinNodeData('HTTP Request');
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.SUCCESSFUL);
// Delete node to create mismatch
await n8n.canvas.deleteNodeByName('HTTP Request');
// Try to copy execution and verify error
await attemptCopyToEditor(n8n);
await n8n.notifications.waitForNotificationAndClose(NOTIFICATIONS.DATA_NOT_IMPORTED);
expect(n8n.page.url()).toContain('/debug');
});
async function attemptCopyToEditor(n8n) {
await n8n.canvas.clickExecutionsTab();
await n8n.executions.clickLastExecutionItem();
await n8n.executions.clickCopyToEditorButton();
}
});

View File

@@ -0,0 +1,107 @@
import { test, expect } from '../fixtures/base';
import { n8nPage } from '../pages/n8nPage';
import type { ApiHelpers } from '../services/api-helper';
const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
const EXECUTE_WORKFLOW_NODE_NAME = 'Execute Sub-workflow';
const NOTION_NODE_NAME = 'Notion';
const NOTION_API_KEY = 'abc123Playwright';
// Example of using API calls in a test
async function getCredentialsForProject(api: ApiHelpers, projectId?: string) {
const params = new URLSearchParams({
includeScopes: 'true',
includeData: 'true',
...(projectId && { filter: JSON.stringify({ projectId }) }),
});
return await api.get('/rest/credentials', params);
}
test.describe('Projects @db:reset', () => {
test.beforeEach(async ({ api, n8n }) => {
await api.enableFeature('sharing');
await api.enableFeature('folders');
await api.enableFeature('advancedPermissions');
await api.enableFeature('projectRole:admin');
await api.enableFeature('projectRole:editor');
await api.setMaxTeamProjectsQuota(-1);
await n8n.goHome();
});
test('should not show project add button and projects to a member if not invited to any project @auth:member', async ({
n8n,
}) => {
await expect(n8n.sideBar.getAddFirstProjectButton()).toBeDisabled();
await expect(n8n.sideBar.getProjectMenuItems()).toHaveCount(0);
});
test('should filter credentials by project ID', async ({ n8n, api }) => {
const { projectName, projectId } = await n8n.projectComposer.createProject();
await n8n.projectComposer.addCredentialToProject(
projectName,
'Notion API',
'apiKey',
NOTION_API_KEY,
);
const credentials = await getCredentialsForProject(api, projectId);
expect(credentials).toHaveLength(1);
const { projectId: project2Id } = await n8n.projectComposer.createProject();
const credentials2 = await getCredentialsForProject(api, project2Id);
expect(credentials2).toHaveLength(0);
});
test('should create sub-workflow and credential in the sub-workflow in the same project @auth:owner', async ({
n8n,
}) => {
const { projectName } = await n8n.projectComposer.createProject();
await n8n.sideBar.addWorkflowFromUniversalAdd(projectName);
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await expect(
n8n.page.getByText('Workflow successfully created', { exact: false }),
).toBeVisible();
await n8n.canvas.addNodeToCanvasWithSubItem(
EXECUTE_WORKFLOW_NODE_NAME,
'Execute A Sub Workflow',
);
const subWorkflowPagePromise = n8n.page.waitForEvent('popup');
await n8n.ndv.selectWorkflowResource(`Create a Sub-Workflow in '${projectName}'`);
const subn8n = new n8nPage(await subWorkflowPagePromise);
await subn8n.ndv.clickBackToCanvasButton();
await subn8n.canvas.deleteNodeByName('Replace me with your logic');
await subn8n.canvas.addNodeToCanvasWithSubItem(NOTION_NODE_NAME, 'Append a block');
await subn8n.credentials.createAndSaveNewCredential('apiKey', NOTION_API_KEY);
await subn8n.ndv.clickBackToCanvasButton();
await subn8n.canvas.saveWorkflow();
await subn8n.page.goto('/home/workflows');
await subn8n.projectWorkflows.clickProjectMenuItem(projectName);
await subn8n.page.getByRole('link', { name: 'Workflows' }).click();
// Get Workflow Count
await expect(subn8n.page.locator('[data-test-id="resources-list-item-workflow"]')).toHaveCount(
2,
);
// Assert that the sub-workflow is in the list
await expect(subn8n.page.getByRole('heading', { name: 'My Sub-Workflow' })).toBeVisible();
// Navigate to Credentials
await subn8n.page.getByRole('link', { name: 'Credentials' }).click();
// Assert that the credential is in the list
await expect(subn8n.page.locator('[data-test-id="resources-list-item"]')).toHaveCount(1);
await expect(subn8n.page.getByRole('heading', { name: 'Notion account' })).toBeVisible();
});
});

View File

@@ -0,0 +1,26 @@
import { test, expect } from '../fixtures/base';
test('default signin is as owner', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(/\/workflow/);
});
test('owner can access dashboard @auth:owner', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(/\/workflow/);
});
test('admin can access dashboard @auth:admin', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(/\/workflow/);
});
test('member can access dashboard @auth:member', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(/\/workflow/);
});
test('no auth can not access dashboard @auth:none', async ({ n8n }) => {
await n8n.goHome();
await expect(n8n.page).toHaveURL(/\/signin/);
});

View File

@@ -0,0 +1,22 @@
import { test, expect } from '../fixtures/base';
test('Leader election @mode:multi-main @chaostest', async ({ chaos }) => {
// First get the container (try main 1 first)
const namePattern = 'n8n-main-*';
const findContainerByLog = await chaos.waitForLog('Leader is now this', {
namePattern,
});
expect(findContainerByLog).toBeDefined();
const currentLeader = findContainerByLog.containerName;
// Stop leader
await chaos.stopContainer(currentLeader);
// Find new leader
const newLeader = await chaos.waitForLog('Leader is now this', {
namePattern,
});
expect(newLeader).toBeDefined();
});

View File

@@ -0,0 +1,15 @@
import { expect, test } from '../fixtures/base';
// Example of importing a workflow from a file
test.describe('PDF Test', () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Can read and write PDF files and extract text', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.clickAddWorklowButton();
await n8n.workflows.importWorkflow('test_pdf_workflow.json', 'PDF Workflow');
await n8n.canvas.clickExecuteWorkflowButton();
await expect(
n8n.notifications.notificationContainerByText('Workflow executed successfully'),
).toBeVisible();
});
});

View File

@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": false,
"lib": ["esnext", "dom"],
"types": ["@playwright/test", "node"]
},
"include": ["**/*.ts"],
"exclude": ["**/dist/**/*", "**/node_modules/**/*"],
"references": [{ "path": "../../workflow/tsconfig.build.esm.json" }]
}

View File

@@ -0,0 +1,32 @@
import * as fs from 'fs';
import * as path from 'path';
import { TestError } from '../Types';
/**
* Finds the project root by searching upwards for a marker file.
* @param marker The file that identifies the project root (e.g., 'playwright.config.ts' or 'package.json').
* @returns The absolute path to the project root.
*/
function findProjectRoot(marker: string): string {
let dir = __dirname;
while (!fs.existsSync(path.join(dir, marker))) {
const parentDir = path.dirname(dir);
if (parentDir === dir) {
throw new TestError('Could not find project root');
}
dir = parentDir;
}
return dir;
}
const playwrightRoot = findProjectRoot('playwright.config.ts');
/**
* Resolves a path relative to the Playwright project root.
* @param pathSegments Segments of the path starting from the project root.
* @returns An absolute path to the file or directory.
*/
export function resolveFromRoot(...pathSegments: string[]): string {
return path.join(playwrightRoot, ...pathSegments);
}

View File

@@ -0,0 +1,61 @@
{
"name": "Test workflow 1",
"nodes": [
{
"parameters": {},
"id": "a2f85497-260d-4489-a957-2b7d88e2f33d",
"name": "On clicking 'execute'",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [220, 260]
},
{
"parameters": {
"jsCode": "// Loop over input items and add a new field\n// called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
},
"id": "9493d278-1ede-47c9-bedf-92ac3a737c65",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [400, 260]
}
],
"pinData": {},
"connections": {
"On clicking 'execute'": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [[]]
}
},
"active": false,
"settings": {},
"hash": "a59c7b1c97b1741597afae0fcd43ebef",
"id": 3,
"meta": {
"instanceId": "a5280676597d00ecd0ea712da7f9cf2ce90174a791a309112731f6e44d162f35"
},
"tags": [
{
"name": "some-tag-1",
"createdAt": "2022-11-10T13:43:34.001Z",
"updatedAt": "2022-11-10T13:43:34.001Z",
"id": "6"
},
{
"name": "some-tag-2",
"createdAt": "2022-11-10T13:43:39.778Z",
"updatedAt": "2022-11-10T13:43:39.778Z",
"id": "7"
}
]
}

View File

@@ -0,0 +1,121 @@
{
"name": "My workflow",
"nodes": [
{
"parameters": {},
"id": "1285fa02-e091-4bd3-89cf-c7e6c174ff49",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [460, -120]
},
{
"parameters": {
"url": "https://ontheline.trincoll.edu/images/bookdown/sample-local-pdf.pdf",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
}
},
"id": "9c8b960d-e7c8-4116-97d4-4f5ca9872898",
"name": "Download PDF",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [680, -120]
},
{
"parameters": {
"operation": "pdf",
"options": {}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1,
"position": [1340, -120],
"id": "5757782b-7029-44d6-b87e-5f9b5910f1b7",
"name": "Extract from File"
},
{
"parameters": {
"operation": "write",
"fileName": "/tmp/downloaded-sample.pdf",
"options": {}
},
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1,
"position": [900, -120],
"id": "2bc250b7-6f9f-4888-a229-beced5821b1b",
"name": "Write PDF"
},
{
"parameters": {
"fileSelector": "/tmp/downloaded-sample.pdf",
"options": {}
},
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1,
"position": [1120, -120],
"id": "e3402ffd-b322-4442-b0d1-18bb19a9a319",
"name": "Read PDF"
}
],
"pinData": {},
"connections": {
"Manual Trigger": {
"main": [
[
{
"node": "Download PDF",
"type": "main",
"index": 0
}
]
]
},
"Download PDF": {
"main": [
[
{
"node": "Write PDF",
"type": "main",
"index": 0
}
]
]
},
"Write PDF": {
"main": [
[
{
"node": "Read PDF",
"type": "main",
"index": 0
}
]
]
},
"Read PDF": {
"main": [
[
{
"node": "Extract from File",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "8a74a3e5-c170-4d21-9821-90a47f3b7987",
"meta": {
"instanceId": "1b71763328f359150fb2679ac09d77f666c592638a32a2dd7f058138ceaf177d"
},
"id": "1sTDcSTRKmMsFNn6",
"tags": []
}

2990
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ packages:
- packages/frontend/**
- packages/extensions/**
- cypress
- packages/testing/**
catalog:
'@n8n/typeorm': 0.3.20-12

View File

@@ -22,7 +22,7 @@ const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const isInScriptsDir = path.basename(scriptDir) === 'scripts';
const rootDir = isInScriptsDir ? path.join(scriptDir, '..') : scriptDir;
// --- Configuration ---
// #region ===== Configuration =====
const config = {
compiledAppDir: process.env.BUILD_OUTPUT_DIR || path.join(rootDir, 'compiled'),
rootDir: rootDir,
@@ -31,7 +31,9 @@ const config = {
// Define backend patches to keep during deployment
const PATCHES_TO_KEEP = ['pdfjs-dist', 'pkce-challenge', 'bull'];
// --- Helper Functions ---
// #endregion ===== Configuration =====
// #region ===== Helper Functions =====
const timers = new Map();
function startTimer(name) {
@@ -63,7 +65,9 @@ function printDivider() {
echo(chalk.gray('-----------------------------------------------'));
}
// --- Main Build Process ---
// #endregion ===== Helper Functions =====
// #region ===== Main Build Process =====
printHeader('n8n Build & Production Preparation');
echo(`INFO: Output Directory: ${config.compiledAppDir}`);
printDivider();
@@ -196,7 +200,9 @@ printDivider();
// Calculate total time
const totalBuildTime = getElapsedTime('total_build');
// --- Final Output ---
// #endregion ===== Main Build Process =====
// #region ===== Final Output =====
echo('');
echo(chalk.green.bold('================ BUILD SUMMARY ================'));
echo(chalk.green(`✅ n8n built successfully!`));
@@ -215,5 +221,7 @@ echo(chalk.blue('📋 Build Manifest:'));
echo(` ${path.resolve(config.compiledAppDir)}/build-manifest.json`);
echo(chalk.green.bold('=============================================='));
// #endregion ===== Final Output =====
// Exit with success
process.exit(0);

View File

@@ -1,88 +1,170 @@
#!/usr/bin/env node
/**
* This script is used to build the n8n docker image locally.
* It simulates how we build for CI and should allow for local testing.
* By default it outputs the tag 'dev' and the image name 'n8n-local:dev'.
* It can be overridden by setting the IMAGE_BASE_NAME and IMAGE_TAG environment variables.
* Build n8n Docker image locally
*
* This script simulates the CI build process for local testing.
* Default output: 'n8nio/n8n:local'
* Override with IMAGE_BASE_NAME and IMAGE_TAG environment variables.
*/
import { $, echo, fs, chalk } from 'zx';
import { $, echo, fs, chalk, os } from 'zx';
import { fileURLToPath } from 'url';
import path from 'path';
// Disable verbose mode for cleaner output
$.verbose = false;
process.env.FORCE_COLOR = '1';
process.env.DOCKER_BUILDKIT = '1';
// --- Determine script location ---
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const isInScriptsDir = path.basename(scriptDir) === 'scripts';
const rootDir = isInScriptsDir ? path.join(scriptDir, '..') : scriptDir;
// #region ===== Helper Functions =====
/**
* Get Docker platform string based on host architecture
* @returns {string} Platform string (e.g., 'linux/amd64')
*/
function getDockerPlatform() {
const arch = os.arch();
const dockerArch = {
x64: 'amd64',
arm64: 'arm64',
}[arch];
if (!dockerArch) {
throw new Error(`Unsupported architecture: ${arch}. Only x64 and arm64 are supported.`);
}
return `linux/${dockerArch}`;
}
/**
* Format duration in seconds
* @param {number} ms - Duration in milliseconds
* @returns {string} Formatted duration
*/
function formatDuration(ms) {
return `${Math.floor(ms / 1000)}s`;
}
/**
* Get Docker image size
* @param {string} imageName - Full image name with tag
* @returns {Promise<string>} Image size or 'Unknown'
*/
async function getImageSize(imageName) {
try {
const { stdout } = await $`docker images ${imageName} --format "{{.Size}}"`;
return stdout.trim();
} catch {
return 'Unknown';
}
}
/**
* Check if a command exists
* @param {string} command - Command to check
* @returns {Promise<boolean>} True if command exists
*/
async function commandExists(command) {
try {
await $`command -v ${command}`;
return true;
} catch {
return false;
}
}
// #endregion ===== Helper Functions =====
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isInScriptsDir = path.basename(__dirname) === 'scripts';
const rootDir = isInScriptsDir ? path.join(__dirname, '..') : __dirname;
// --- Configuration ---
const config = {
dockerfilePath: path.join(rootDir, 'docker/images/n8n/Dockerfile'),
imageBaseName: process.env.IMAGE_BASE_NAME || 'n8n-local',
imageTag: process.env.IMAGE_TAG || 'dev',
imageBaseName: process.env.IMAGE_BASE_NAME || 'n8nio/n8n',
imageTag: process.env.IMAGE_TAG || 'local',
buildContext: rootDir,
compiledAppDir: path.join(rootDir, 'compiled'),
get fullImageName() {
return `${this.imageBaseName}:${this.imageTag}`;
},
};
config.fullImageName = `${config.imageBaseName}:${config.imageTag}`;
// #region ===== Main Build Process =====
// --- Check Prerequisites ---
echo(chalk.blue.bold('===== Docker Build for n8n ====='));
echo(`INFO: Image: ${config.fullImageName}`);
echo(chalk.gray('-----------------------------------------------'));
const platform = getDockerPlatform();
// Check if compiled directory exists
if (!(await fs.pathExists(config.compiledAppDir))) {
echo(chalk.red(`Error: Compiled app directory not found at ${config.compiledAppDir}`));
echo(chalk.yellow('Please run build-n8n.mjs first!'));
async function main() {
echo(chalk.blue.bold('===== Docker Build for n8n ====='));
echo(`INFO: Image: ${config.fullImageName}`);
echo(`INFO: Platform: ${platform}`);
echo(chalk.gray('-'.repeat(47)));
await checkPrerequisites();
// Build Docker image
const buildTime = await buildDockerImage();
// Get image details
const imageSize = await getImageSize(config.fullImageName);
// Display summary
displaySummary({
imageName: config.fullImageName,
platform,
size: imageSize,
buildTime,
});
}
async function checkPrerequisites() {
if (!(await fs.pathExists(config.compiledAppDir))) {
echo(chalk.red(`Error: Compiled app directory not found at ${config.compiledAppDir}`));
echo(chalk.yellow('Please run build-n8n.mjs first!'));
process.exit(1);
}
if (!(await commandExists('docker'))) {
echo(chalk.red('Error: Docker is not installed or not in PATH'));
process.exit(1);
}
}
async function buildDockerImage() {
const startTime = Date.now();
echo(chalk.yellow('INFO: Building Docker image...'));
try {
const { stdout } = await $`DOCKER_BUILDKIT=1 docker build \
--platform ${platform} \
-t ${config.fullImageName} \
-f ${config.dockerfilePath} \
${config.buildContext}`;
echo(stdout);
return formatDuration(Date.now() - startTime);
} catch (error) {
echo(chalk.red(`ERROR: Docker build failed: ${error.stderr || error.message}`));
process.exit(1);
}
}
function displaySummary({ imageName, platform, size, buildTime }) {
echo('');
echo(chalk.green.bold('═'.repeat(54)));
echo(chalk.green.bold(' DOCKER BUILD COMPLETE'));
echo(chalk.green.bold('═'.repeat(54)));
echo(chalk.green(`✅ Image built: ${imageName}`));
echo(` Platform: ${platform}`);
echo(` Size: ${size}`);
echo(` Build time: ${buildTime}`);
echo(chalk.green.bold('═'.repeat(54)));
}
// #endregion ===== Main Build Process =====
main().catch((error) => {
echo(chalk.red(`Unexpected error: ${error.message}`));
process.exit(1);
}
// Check Docker
try {
await $`command -v docker`;
} catch {
echo(chalk.red('Error: Docker is not installed or not in PATH'));
process.exit(1);
}
// --- Build Docker Image ---
const startTime = Date.now();
echo(chalk.yellow('INFO: Building Docker image...'));
try {
const buildOutput = await $`docker build \
-t ${config.fullImageName} \
-f ${config.dockerfilePath} \
${config.buildContext}`;
echo(buildOutput.stdout);
} catch (error) {
echo(chalk.red(`ERROR: Docker build failed: ${error.stderr || error.message}`));
process.exit(1);
}
const buildTime = Math.floor((Date.now() - startTime) / 1000);
// Get image size
let imageSize = 'Unknown';
try {
const sizeOutput = await $`docker images ${config.fullImageName} --format "{{.Size}}"`;
imageSize = sizeOutput.stdout.trim();
} catch (error) {
echo(chalk.yellow('Warning: Could not get image size'));
}
// --- Summary ---
echo('');
echo(chalk.green.bold('================ DOCKER BUILD COMPLETE ================'));
echo(chalk.green(`✅ Image built: ${config.fullImageName}`));
echo(` Size: ${imageSize}`);
echo(` Build time: ${buildTime}s`);
echo(chalk.green.bold('===================================================='));
// Exit with success
process.exit(0);
});

View File

@@ -14,10 +14,10 @@ const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const isInScriptsDir = path.basename(scriptDir) === 'scripts';
const rootDir = isInScriptsDir ? path.join(scriptDir, '..') : scriptDir;
// --- Configuration ---
// #region ===== Configuration =====
const config = {
imageBaseName: process.env.IMAGE_BASE_NAME || 'n8n-local',
imageTag: process.env.IMAGE_TAG || 'dev',
imageBaseName: process.env.IMAGE_BASE_NAME || 'n8nio/n8n',
imageTag: process.env.IMAGE_TAG || 'local',
trivyImage: process.env.TRIVY_IMAGE || 'aquasec/trivy:latest',
severity: process.env.TRIVY_SEVERITY || 'CRITICAL,HIGH,MEDIUM,LOW',
outputFormat: process.env.TRIVY_FORMAT || 'table',
@@ -56,7 +56,9 @@ const printSummary = (status, time, message) => {
echo(chalk.blue.bold('========================'));
};
// --- Main Process ---
// #endregion ===== Configuration =====
// #region ===== Main Process =====
(async () => {
printHeader('Trivy Security Scan for n8n Image');
@@ -150,3 +152,5 @@ const printSummary = (status, time, message) => {
}
}
})();
// #endregion ===== Main Process =====

View File

@@ -11,6 +11,9 @@
"clean": {
"cache": false
},
"build:playwright": {
"dependsOn": ["install-browsers", "build"]
},
"build:backend": {
"dependsOn": ["n8n#build"]
},
@@ -139,6 +142,12 @@
"dev": {
"cache": false,
"persistent": true
},
"install-browsers": {
"cache": true,
"inputs": ["package.json"],
"outputs": ["ms-playwright-cache/**"],
"env": ["PLAYWRIGHT_BROWSERS_PATH"]
}
}
}