mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat: Allow running Playwright e2e tests using Vite dev server (no-changelog) (#22742)
This commit is contained in:
@@ -21,7 +21,9 @@
|
||||
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
|
||||
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
|
||||
"dev:fe": "run-p start \"dev:fe:editor --filter=@n8n/design-system\"",
|
||||
"dev:fe:e2e": "run-p start dev:fe:editor",
|
||||
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
|
||||
"dev:e2e": "pnpm --filter=n8n-playwright dev --ui",
|
||||
"clean": "turbo run clean",
|
||||
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
||||
"format": "turbo run format && node scripts/format.mjs",
|
||||
|
||||
@@ -13,7 +13,24 @@ pnpm test:local # Starts a local server and runs the UI tes
|
||||
N8N_BASE_URL=localhost:5068 pnpm test:local # Runs the UI tests against the instance running
|
||||
```
|
||||
|
||||
## Separate Backend and Frontend URLs
|
||||
|
||||
When developing with separate backend and frontend servers (e.g., backend on port 5680, frontend on port 8080), you can use the following environment variables:
|
||||
|
||||
- **`N8N_BASE_URL`**: Backend server URL (also used as frontend URL if `N8N_EDITOR_URL` is not set)
|
||||
- **`N8N_EDITOR_URL`**: Frontend server URL (when set, overrides frontend URL while backend uses `N8N_BASE_URL`)
|
||||
|
||||
**How it works:**
|
||||
- **Backend URL** (for API calls): Always uses `N8N_BASE_URL`
|
||||
- **Frontend URL** (for browser navigation): Uses `N8N_EDITOR_URL` if set, otherwise falls back to `N8N_BASE_URL`
|
||||
|
||||
This allows you to:
|
||||
- Test against a backend on port 5680 while the frontend dev server runs on port 8080
|
||||
- Use different URLs for API calls vs browser navigation
|
||||
- Maintain backward compatibility with single-URL setups
|
||||
|
||||
## Test Commands
|
||||
|
||||
```bash
|
||||
# By Mode
|
||||
pnpm test:container:standard # Sqlite
|
||||
|
||||
@@ -5,12 +5,14 @@ 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 { N8N_AUTH_COOKIE } from '../config/constants';
|
||||
import { setupDefaultInterceptors } from '../config/intercepts';
|
||||
import { n8nPage } from '../pages/n8nPage';
|
||||
import { ApiHelpers } from '../services/api-helper';
|
||||
import { ProxyServer } from '../services/proxy-server';
|
||||
import { TestError, type TestRequirements } from '../Types';
|
||||
import { setupTestRequirements } from '../utils/requirements';
|
||||
import { getBackendUrl, getFrontendUrl } from '../utils/url-helper';
|
||||
|
||||
type TestFixtures = {
|
||||
n8n: n8nPage;
|
||||
@@ -22,6 +24,8 @@ type TestFixtures = {
|
||||
|
||||
type WorkerFixtures = {
|
||||
n8nUrl: string;
|
||||
backendUrl: string;
|
||||
frontendUrl: string;
|
||||
dbSetup: undefined;
|
||||
chaos: ContainerTestHelpers;
|
||||
n8nContainer: N8NStack;
|
||||
@@ -90,10 +94,10 @@ export const test = base.extend<
|
||||
{ scope: 'worker', box: true },
|
||||
],
|
||||
|
||||
// Create a new n8n container if N8N_BASE_URL is not set, otherwise use the existing n8n instance
|
||||
// Create a new n8n container if backend URL is not set, otherwise use the existing n8n instance
|
||||
n8nContainer: [
|
||||
async ({ containerConfig }, use, workerInfo) => {
|
||||
const envBaseURL = process.env.N8N_BASE_URL;
|
||||
const envBaseURL = getBackendUrl();
|
||||
|
||||
if (envBaseURL) {
|
||||
await use(null as unknown as N8NStack);
|
||||
@@ -132,12 +136,32 @@ export const test = base.extend<
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
// Backend URL - used for API calls
|
||||
// When N8N_BASE_URL is set, use it; otherwise fall back to n8nUrl
|
||||
backendUrl: [
|
||||
async ({ n8nContainer }, use) => {
|
||||
const envBackendURL = getBackendUrl() ?? n8nContainer?.baseUrl;
|
||||
await use(envBackendURL);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
// Frontend URL - used for browser navigation
|
||||
// When N8N_EDITOR_URL is set (dev mode), use it; otherwise fall back to n8nUrl
|
||||
frontendUrl: [
|
||||
async ({ n8nContainer }, use) => {
|
||||
const envFrontendURL = getFrontendUrl() ?? n8nContainer?.baseUrl;
|
||||
await use(envFrontendURL);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
// Reset the database for the new container
|
||||
dbSetup: [
|
||||
async ({ n8nUrl, n8nContainer }, use) => {
|
||||
async ({ backendUrl, n8nContainer }, use) => {
|
||||
if (n8nContainer) {
|
||||
console.log('Resetting database for new container');
|
||||
const apiContext = await request.newContext({ baseURL: n8nUrl });
|
||||
const apiContext = await request.newContext({ baseURL: backendUrl });
|
||||
const api = new ApiHelpers(apiContext);
|
||||
await api.resetDatabase();
|
||||
await apiContext.dispose();
|
||||
@@ -150,7 +174,7 @@ export const test = base.extend<
|
||||
// Create container test helpers for the n8n container.
|
||||
chaos: [
|
||||
async ({ n8nContainer }, use) => {
|
||||
if (process.env.N8N_BASE_URL) {
|
||||
if (getBackendUrl()) {
|
||||
throw new TestError(
|
||||
'Chaos testing is not supported when using N8N_BASE_URL environment variable. Remove N8N_BASE_URL to use containerized testing.',
|
||||
);
|
||||
@@ -161,26 +185,108 @@ export const test = base.extend<
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
baseURL: async ({ n8nUrl, dbSetup }, use) => {
|
||||
baseURL: async ({ frontendUrl, dbSetup }, use) => {
|
||||
void dbSetup; // Ensure dbSetup runs first
|
||||
await use(n8nUrl);
|
||||
await use(frontendUrl);
|
||||
},
|
||||
|
||||
n8n: async ({ context }, use, testInfo) => {
|
||||
n8n: async ({ context, backendUrl, frontendUrl }, use, testInfo) => {
|
||||
await setupDefaultInterceptors(context);
|
||||
const page = await context.newPage();
|
||||
const n8nInstance = new n8nPage(page);
|
||||
await n8nInstance.api.setupFromTags(testInfo.tags);
|
||||
// Enable project features for the tests, this is used in several tests, but is never disabled in tests, so we can have it on by default
|
||||
await n8nInstance.start.withProjectFeatures();
|
||||
await use(n8nInstance);
|
||||
|
||||
// Only create a separate API context when backend and frontend URLs differ
|
||||
const useSeparateApiContext = backendUrl !== frontendUrl;
|
||||
|
||||
if (useSeparateApiContext) {
|
||||
// Create a separate API context with backend URL for API calls
|
||||
const apiContext = await request.newContext({ baseURL: backendUrl });
|
||||
const api = new ApiHelpers(apiContext);
|
||||
|
||||
const n8nInstance = new n8nPage(page, api);
|
||||
await n8nInstance.api.setupFromTags(testInfo.tags);
|
||||
|
||||
// Authentication strategy:
|
||||
// - No @auth: tag → Sign in as owner (default)
|
||||
// - @auth:none → Stay unauthenticated (for testing sign in flows)
|
||||
// - @auth:member, @auth:admin etc → Handled by setupFromTags above
|
||||
const hasAuthTag = testInfo.tags.some((tag) => tag.startsWith('@auth:'));
|
||||
|
||||
// Check if already authenticated from setupFromTags
|
||||
let apiCookies = await apiContext.storageState();
|
||||
let authCookie = apiCookies.cookies.find((cookie) => cookie.name === N8N_AUTH_COOKIE);
|
||||
|
||||
// Default to owner authentication when no auth tag is specified
|
||||
if (!hasAuthTag && !authCookie) {
|
||||
await api.signin('owner');
|
||||
apiCookies = await apiContext.storageState();
|
||||
authCookie = apiCookies.cookies.find((cookie) => cookie.name === N8N_AUTH_COOKIE);
|
||||
}
|
||||
|
||||
// Transfer authentication cookies from API context (backend) to browser context (frontend)
|
||||
if (authCookie) {
|
||||
const backendUrlParsed = new URL(backendUrl);
|
||||
const frontendUrlParsed = new URL(frontendUrl);
|
||||
|
||||
if (backendUrlParsed.hostname === frontendUrlParsed.hostname) {
|
||||
// Same host (e.g. localhost different ports) → use domain-based cookie
|
||||
await context.addCookies([
|
||||
{
|
||||
...authCookie,
|
||||
domain: frontendUrlParsed.hostname,
|
||||
path: '/',
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
// Different hosts → use URL-based cookie setting
|
||||
await context.addCookies([
|
||||
{
|
||||
name: authCookie.name,
|
||||
value: authCookie.value,
|
||||
url: frontendUrl,
|
||||
path: '/',
|
||||
httpOnly: authCookie.httpOnly,
|
||||
secure: authCookie.secure,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
// Enable project features for the tests, this is used in several tests, but is never disabled in tests, so we can have it on by default
|
||||
await n8nInstance.start.withProjectFeatures();
|
||||
await use(n8nInstance);
|
||||
await apiContext.dispose();
|
||||
} else {
|
||||
const n8nInstance = new n8nPage(page);
|
||||
await n8nInstance.api.setupFromTags(testInfo.tags);
|
||||
|
||||
// Enable project features for the tests, this is used in several tests, but is never disabled in tests, so we can have it on by default
|
||||
await n8nInstance.start.withProjectFeatures();
|
||||
await use(n8nInstance);
|
||||
}
|
||||
},
|
||||
|
||||
// This is a completely isolated API context for tests that don't need the browser
|
||||
api: async ({ baseURL }, use, testInfo) => {
|
||||
const context = await request.newContext({ baseURL });
|
||||
api: async ({ backendUrl }, use, testInfo) => {
|
||||
const context = await request.newContext({ baseURL: backendUrl });
|
||||
const api = new ApiHelpers(context);
|
||||
await api.setupFromTags(testInfo.tags);
|
||||
|
||||
// Authentication strategy:
|
||||
// - No @auth: tag → Sign in as owner (default)
|
||||
// - @auth:none → Stay unauthenticated (for testing sign in flows)
|
||||
// - @auth:member, @auth:admin etc → Handled by setupFromTags above
|
||||
const hasAuthTag = testInfo.tags.some((tag) => tag.startsWith('@auth:'));
|
||||
|
||||
// Check if already authenticated from setupFromTags
|
||||
const apiCookies = await context.storageState();
|
||||
const authCookie = apiCookies.cookies.find((cookie) => cookie.name === N8N_AUTH_COOKIE);
|
||||
|
||||
// Default to owner authentication when no auth tag is specified
|
||||
if (!hasAuthTag && !authCookie) {
|
||||
await api.signin('owner');
|
||||
}
|
||||
|
||||
await use(api);
|
||||
await context.dispose();
|
||||
},
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { request } from '@playwright/test';
|
||||
|
||||
import { ApiHelpers } from './services/api-helper';
|
||||
import { getBackendUrl } from './utils/url-helper';
|
||||
|
||||
async function globalSetup() {
|
||||
console.log('🚀 Starting global setup...');
|
||||
|
||||
// Check if N8N_BASE_URL is set
|
||||
const n8nBaseUrl = process.env.N8N_BASE_URL;
|
||||
// Check if backend URL is set (N8N_BACKEND_URL or N8N_BASE_URL)
|
||||
const n8nBaseUrl = getBackendUrl();
|
||||
if (!n8nBaseUrl) {
|
||||
console.log('⚠️ N8N_BASE_URL environment variable is not set, skipping database reset');
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "n8n-playwright",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "N8N_BASE_URL=http://localhost:5678 N8N_EDITOR_URL=http://localhost:8080 RESET_E2E_DB=true playwright test --project=ui --project=ui:isolated",
|
||||
"test:all": "playwright test",
|
||||
"test:local": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=ui --project=ui:isolated",
|
||||
"test:local:ui-only": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=ui",
|
||||
|
||||
@@ -120,9 +120,9 @@ export class n8nPage {
|
||||
readonly breadcrumbs: Breadcrumbs;
|
||||
readonly clipboard: ClipboardHelper;
|
||||
|
||||
constructor(page: Page) {
|
||||
constructor(page: Page, api?: ApiHelpers) {
|
||||
this.page = page;
|
||||
this.api = new ApiHelpers(page.context().request);
|
||||
this.api = api ?? new ApiHelpers(page.context().request);
|
||||
|
||||
// Pages
|
||||
this.aiAssistant = new AIAssistantPage(page);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Project } from '@playwright/test';
|
||||
import type { N8NConfig } from 'n8n-containers/n8n-test-container-creation';
|
||||
|
||||
import { getBackendUrl, getFrontendUrl } from './utils/url-helper';
|
||||
|
||||
// Tags that require test containers environment
|
||||
// These tests won't be run against local
|
||||
const CONTAINER_ONLY_TAGS = [
|
||||
@@ -31,7 +33,7 @@ const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
|
||||
];
|
||||
|
||||
export function getProjects(): Project[] {
|
||||
const isLocal = !!process.env.N8N_BASE_URL;
|
||||
const isLocal = !!getBackendUrl();
|
||||
const projects: Project[] = [];
|
||||
|
||||
if (isLocal) {
|
||||
@@ -43,14 +45,14 @@ export function getProjects(): Project[] {
|
||||
[CONTAINER_ONLY.source, SERIAL_EXECUTION.source, ISOLATED_ONLY.source].join('|'),
|
||||
),
|
||||
fullyParallel: true,
|
||||
use: { baseURL: process.env.N8N_BASE_URL },
|
||||
use: { baseURL: getFrontendUrl() },
|
||||
},
|
||||
{
|
||||
name: 'ui:isolated',
|
||||
testDir: './tests/e2e',
|
||||
grep: new RegExp([SERIAL_EXECUTION.source, ISOLATED_ONLY.source].join('|')),
|
||||
workers: 1,
|
||||
use: { baseURL: process.env.N8N_BASE_URL },
|
||||
use: { baseURL: getFrontendUrl() },
|
||||
},
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -7,9 +7,10 @@ import path from 'path';
|
||||
|
||||
import currentsConfig from './currents.config';
|
||||
import { getProjects } from './playwright-projects';
|
||||
import { getPortFromUrl } from './utils/url-helper';
|
||||
import { getBackendUrl, getFrontendUrl, getPortFromUrl } from './utils/url-helper';
|
||||
|
||||
const IS_CI = !!process.env.CI;
|
||||
const IS_DEV = !!process.env.N8N_EDITOR_URL;
|
||||
|
||||
const MACBOOK_WINDOW_SIZE = { width: 1536, height: 960 };
|
||||
|
||||
@@ -34,7 +35,14 @@ const getTestEnv = () => {
|
||||
const CPU_COUNT = os.cpus().length;
|
||||
const LOCAL_WORKERS = Math.min(6, Math.floor(CPU_COUNT / 2));
|
||||
const CI_WORKERS = CPU_COUNT;
|
||||
const WORKERS = IS_CI ? CI_WORKERS : LOCAL_WORKERS;
|
||||
const WORKERS = IS_DEV ? 1 : IS_CI ? CI_WORKERS : LOCAL_WORKERS;
|
||||
|
||||
const BACKEND_URL = getBackendUrl();
|
||||
const FRONTEND_URL = getFrontendUrl();
|
||||
const START_COMMAND = IS_DEV ? 'pnpm dev:fe:e2e' : 'pnpm start';
|
||||
const WEB_SERVER_URL = FRONTEND_URL ?? BACKEND_URL;
|
||||
|
||||
const EXPECT_TIMEOUT = IS_DEV ? 20000 : 10000;
|
||||
|
||||
export default defineConfig<CurrentsFixtures, CurrentsWorkerFixtures>({
|
||||
globalSetup: './global-setup.ts',
|
||||
@@ -43,21 +51,21 @@ export default defineConfig<CurrentsFixtures, CurrentsWorkerFixtures>({
|
||||
workers: WORKERS,
|
||||
timeout: 60000,
|
||||
expect: {
|
||||
timeout: 10000,
|
||||
timeout: EXPECT_TIMEOUT,
|
||||
},
|
||||
projects: getProjects(),
|
||||
|
||||
// We use this if an n8n url is passed in. If the server is already running, we reuse it.
|
||||
webServer: process.env.N8N_BASE_URL
|
||||
webServer: BACKEND_URL
|
||||
? {
|
||||
command: 'cd .. && pnpm start',
|
||||
url: `${process.env.N8N_BASE_URL}/favicon.ico`,
|
||||
timeout: 20000,
|
||||
command: `cd .. && ${START_COMMAND}`,
|
||||
url: `${WEB_SERVER_URL}/favicon.ico`,
|
||||
timeout: 30000,
|
||||
reuseExistingServer: true,
|
||||
env: {
|
||||
DB_SQLITE_POOL_SIZE: '40',
|
||||
E2E_TESTS: 'true',
|
||||
N8N_PORT: getPortFromUrl(process.env.N8N_BASE_URL),
|
||||
N8N_PORT: getPortFromUrl(BACKEND_URL),
|
||||
N8N_USER_FOLDER: USER_FOLDER,
|
||||
N8N_LOG_LEVEL: 'debug',
|
||||
N8N_METRICS: 'true',
|
||||
|
||||
@@ -5,3 +5,20 @@ export function getPortFromUrl(url: string): string {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.port || (parsedUrl.protocol === 'https:' ? '443' : '80');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend URL from environment variables
|
||||
* Returns N8N_BASE_URL
|
||||
*/
|
||||
export function getBackendUrl(): string | undefined {
|
||||
return process.env.N8N_BASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the frontend URL from environment variables
|
||||
* When N8N_EDITOR_URL is set (dev mode), use it for the frontend
|
||||
* Otherwise, use the same URL as the backend
|
||||
*/
|
||||
export function getFrontendUrl(): string | undefined {
|
||||
return process.env.N8N_EDITOR_URL ?? process.env.N8N_BASE_URL;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user