mirror of
https://github.com/n8n-io/n8n.git
synced 2025-12-05 19:27:26 -06:00
feat: Add testcontainers and Playwright (no-changelog) (#16662)
Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
170
packages/testing/containers/README.md
Normal file
170
packages/testing/containers/README.md
Normal 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
|
||||
```
|
||||
22
packages/testing/containers/docker-image-not-found-error.ts
Normal file
22
packages/testing/containers/docker-image-not-found-error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
202
packages/testing/containers/n8n-start-stack.ts
Executable file
202
packages/testing/containers/n8n-start-stack.ts
Executable 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);
|
||||
});
|
||||
}
|
||||
340
packages/testing/containers/n8n-test-container-creation.ts
Normal file
340
packages/testing/containers/n8n-test-container-creation.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
189
packages/testing/containers/n8n-test-container-dependencies.ts
Normal file
189
packages/testing/containers/n8n-test-container-dependencies.ts
Normal 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?
|
||||
377
packages/testing/containers/n8n-test-container-helpers.ts
Normal file
377
packages/testing/containers/n8n-test-container-helpers.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
23
packages/testing/containers/package.json
Normal file
23
packages/testing/containers/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
packages/testing/playwright/.eslintrc.js
Normal file
41
packages/testing/playwright/.eslintrc.js
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
41
packages/testing/playwright/README.md
Normal file
41
packages/testing/playwright/README.md
Normal 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
|
||||
6
packages/testing/playwright/Types.ts
Normal file
6
packages/testing/playwright/Types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class TestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TestError';
|
||||
}
|
||||
}
|
||||
15
packages/testing/playwright/composables/CanvasComposer.ts
Normal file
15
packages/testing/playwright/composables/CanvasComposer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
56
packages/testing/playwright/composables/ProjectComposer.ts
Normal file
56
packages/testing/playwright/composables/ProjectComposer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
packages/testing/playwright/composables/WorkflowComposer.ts
Normal file
25
packages/testing/playwright/composables/WorkflowComposer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
43
packages/testing/playwright/config/constants.ts
Normal file
43
packages/testing/playwright/config/constants.ts
Normal 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',
|
||||
};
|
||||
105
packages/testing/playwright/config/intercepts.ts
Normal file
105
packages/testing/playwright/config/intercepts.ts
Normal 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>',
|
||||
},
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
77
packages/testing/playwright/config/test-users.ts
Normal file
77
packages/testing/playwright/config/test-users.ts
Normal 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(),
|
||||
},
|
||||
];
|
||||
6
packages/testing/playwright/currents.config.ts
Normal file
6
packages/testing/playwright/currents.config.ts
Normal 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',
|
||||
};
|
||||
149
packages/testing/playwright/fixtures/base.ts
Normal file
149
packages/testing/playwright/fixtures/base.ts
Normal 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]
|
||||
*/
|
||||
43
packages/testing/playwright/global-setup.ts
Normal file
43
packages/testing/playwright/global-setup.ts
Normal 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;
|
||||
23
packages/testing/playwright/package.json
Normal file
23
packages/testing/playwright/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
21
packages/testing/playwright/pages/BasePage.ts
Normal file
21
packages/testing/playwright/pages/BasePage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
117
packages/testing/playwright/pages/CanvasPage.ts
Normal file
117
packages/testing/playwright/pages/CanvasPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
65
packages/testing/playwright/pages/CredentialsPage.ts
Normal file
65
packages/testing/playwright/pages/CredentialsPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
36
packages/testing/playwright/pages/ExecutionsPage.ts
Normal file
36
packages/testing/playwright/pages/ExecutionsPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
38
packages/testing/playwright/pages/NodeDisplayViewPage.ts
Normal file
38
packages/testing/playwright/pages/NodeDisplayViewPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
234
packages/testing/playwright/pages/NotificationsPage.ts
Normal file
234
packages/testing/playwright/pages/NotificationsPage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/testing/playwright/pages/ProjectSettingsPage.ts
Normal file
11
packages/testing/playwright/pages/ProjectSettingsPage.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
11
packages/testing/playwright/pages/ProjectWorkflowsPage.ts
Normal file
11
packages/testing/playwright/pages/ProjectWorkflowsPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
42
packages/testing/playwright/pages/SidebarPage.ts
Normal file
42
packages/testing/playwright/pages/SidebarPage.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
45
packages/testing/playwright/pages/WorkflowsPage.ts
Normal file
45
packages/testing/playwright/pages/WorkflowsPage.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
68
packages/testing/playwright/pages/n8nPage.ts
Normal file
68
packages/testing/playwright/pages/n8nPage.ts
Normal 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('/');
|
||||
}
|
||||
}
|
||||
127
packages/testing/playwright/playwright.config.ts
Normal file
127
packages/testing/playwright/playwright.config.ts
Normal 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)),
|
||||
});
|
||||
238
packages/testing/playwright/services/api-helper.ts
Normal file
238
packages/testing/playwright/services/api-helper.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
11
packages/testing/playwright/tests/1-workflows.spec.ts
Normal file
11
packages/testing/playwright/tests/1-workflows.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
113
packages/testing/playwright/tests/28-debug.spec.ts
Normal file
113
packages/testing/playwright/tests/28-debug.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
107
packages/testing/playwright/tests/39-projects.spec.ts
Normal file
107
packages/testing/playwright/tests/39-projects.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
26
packages/testing/playwright/tests/authenticated.spec.ts
Normal file
26
packages/testing/playwright/tests/authenticated.spec.ts
Normal 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/);
|
||||
});
|
||||
22
packages/testing/playwright/tests/multimain.spec.ts
Normal file
22
packages/testing/playwright/tests/multimain.spec.ts
Normal 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();
|
||||
});
|
||||
15
packages/testing/playwright/tests/pdf.spec.ts
Normal file
15
packages/testing/playwright/tests/pdf.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
12
packages/testing/playwright/tsconfig.json
Normal file
12
packages/testing/playwright/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
32
packages/testing/playwright/utils/path-helper.ts
Normal file
32
packages/testing/playwright/utils/path-helper.ts
Normal 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);
|
||||
}
|
||||
61
packages/testing/playwright/workflows/Test_workflow_1.json
Normal file
61
packages/testing/playwright/workflows/Test_workflow_1.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
121
packages/testing/playwright/workflows/test_pdf_workflow.json
Normal file
121
packages/testing/playwright/workflows/test_pdf_workflow.json
Normal 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
2990
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ packages:
|
||||
- packages/frontend/**
|
||||
- packages/extensions/**
|
||||
- cypress
|
||||
- packages/testing/**
|
||||
|
||||
catalog:
|
||||
'@n8n/typeorm': 0.3.20-12
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user