feat: Allow running Playwright e2e tests using Vite dev server (no-changelog) (#22742)

This commit is contained in:
Alex Grozav
2025-12-09 14:21:21 +00:00
committed by GitHub
parent c6d74234c6
commit e1e4c821a4
9 changed files with 184 additions and 30 deletions
+2
View File
@@ -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",
+17
View File
@@ -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
+121 -15
View File
@@ -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();
},
+3 -2
View File
@@ -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;
+1
View File
@@ -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",
+2 -2
View File
@@ -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;
}