diff --git a/.github/workflows/test-dev-server-smoke-reusable.yml b/.github/workflows/test-dev-server-smoke-reusable.yml index 9c9cae1d4c8..b19a3d6a3d0 100644 --- a/.github/workflows/test-dev-server-smoke-reusable.yml +++ b/.github/workflows/test-dev-server-smoke-reusable.yml @@ -16,7 +16,7 @@ jobs: smoke: name: Dev-server smoke runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }} - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read @@ -33,9 +33,18 @@ jobs: - name: Install Browsers run: pnpm turbo run install-browsers --filter=n8n-playwright + # Both steps run from repo root so PLAYWRIGHT_BROWSERS_PATH (relative) + # resolves correctly. cd-ing into the playwright package double-nests it. + - name: Run dev-server smoke spec (non-default ports) + # Runs FIRST, while nothing has ever listened on 5678, so a regression + # to the 5678 fallback fails with a connection error instead of being + # satisfied by a server leaked from the default run. The broker moves + # too, so it cannot squat on 5679 for the run below. + run: pnpm --filter=n8n-playwright test:dev-server-smoke:alt-port --reporter=list + - name: Run dev-server smoke spec - # Run from repo root so PLAYWRIGHT_BROWSERS_PATH (relative) resolves - # correctly. cd-ing into the playwright package double-nests it. + # Always run: a flake above must not cost the default-port signal. + if: ${{ !cancelled() }} run: pnpm --filter=n8n-playwright test:dev-server-smoke --reporter=list - name: Upload Failure Artifacts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 978c014e608..057114f6775 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -235,6 +235,16 @@ does not exist: it prints a notice and exits with code 0. Given the size of the code base and the number of modules, we recommend only watching the modules you're actively working on. +The dev servers default to 5678 (backend) and 8080 (editor). Set `N8N_PORT` and +`N8N_EDITOR_PORT` to relocate them, for example to run a second instance beside +your main one. The editor derives its REST base URL from `N8N_PORT`, so pass it +to both commands: + +```bash +N8N_PORT=5699 pnpm dev:be +N8N_PORT=5699 N8N_EDITOR_PORT=8082 pnpm dev:fe:editor +``` + ### Basic Development Workflow Example (most used within n8n) If you're working on API and FE, a lot of team members run the following steps: diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index ad92b8ea6de..7ad520663a6 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -18,7 +18,7 @@ "lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache", "format": "biome format --write . && prettier --write . --ignore-path ../../../.prettierignore", "format:check": "biome ci . && prettier --check . --ignore-path ../../../.prettierignore", - "serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vite --host 0.0.0.0 --port 8080 --strictPort dev", + "serve": "vite dev", "test": "vitest run", "test:changed": "janitor test-scoped", "test:dev": "vitest --silent=false" diff --git a/packages/frontend/editor-ui/vite.config.mts b/packages/frontend/editor-ui/vite.config.mts index 79722c8d9a4..b3d46d87666 100644 --- a/packages/frontend/editor-ui/vite.config.mts +++ b/packages/frontend/editor-ui/vite.config.mts @@ -16,11 +16,16 @@ import browserslist from 'browserslist'; import { isLocaleFile, sendLocaleUpdate } from './vite/i18n-locales-hmr-helpers'; import { nodePopularityPlugin } from './vite/vite-plugin-node-popularity.mjs'; import { editorUiAliases } from './vite/aliases.mjs'; +import { DEFAULT_BACKEND_PORT, devServerPlugin, readDevPort } from './vite/dev-ports.mjs'; const publicPath = process.env.VUE_APP_PUBLIC_PATH || '/'; const { NODE_ENV } = process.env; +// Only reachable through the dev server (see the `ctx.server` guard below), +// which `devServerPlugin` has already validated by the time it runs. +const devBackendPort = readDevPort(process.env, 'N8N_PORT', DEFAULT_BACKEND_PORT); + const browsers = browserslist.loadConfig({ path: process.cwd() }); const packagesDir = resolve(__dirname, '..', '..'); @@ -34,6 +39,7 @@ const alias = editorUiAliases(__dirname, packagesDir); const { RELEASE: release } = process.env; const plugins: UserConfig['plugins'] = [ + devServerPlugin(process.env), nodePopularityPlugin(), lucideIconsPlugin(), icons({ @@ -96,7 +102,7 @@ const plugins: UserConfig['plugins'] = [ return ctx.server ? html .replace('%CONFIG_TAGS%', '') - .replaceAll('/{{BASE_PATH}}', `//localhost:${process.env.N8N_PORT ?? '5678'}`) + .replaceAll('/{{BASE_PATH}}', `//localhost:${devBackendPort}`) .replaceAll('/{{REST_ENDPOINT}}', '/rest') : html; }, diff --git a/packages/frontend/editor-ui/vite/dev-ports.mts b/packages/frontend/editor-ui/vite/dev-ports.mts new file mode 100644 index 00000000000..bd630decba3 --- /dev/null +++ b/packages/frontend/editor-ui/vite/dev-ports.mts @@ -0,0 +1,55 @@ +import type { Plugin } from 'vite'; + +export const DEFAULT_BACKEND_PORT = 5678; +export const DEFAULT_EDITOR_PORT = 8080; + +// `||` not `??`: an explicitly empty env var must fall back like an unset one. +export const readDevPort = (env: NodeJS.ProcessEnv, name: string, fallback: number): number => + Number(env[name] || fallback); + +const assertDevPort = (env: NodeJS.ProcessEnv, name: string, fallback: number): number => { + const port = readDevPort(env, name, fallback); + // Vite silently boots on its own default 5173 when handed NaN, so an + // unvalidated typo surfaces as a connection timeout minutes later. + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`${name} must be a port number, got: ${env[name]}`); + } + return port; +}; + +/** + * N8N_PORT is the backend's own listen-port var. It moves both the injected + * BASE_PATH and the REST base URL, so the pair relocates a whole dev instance + * next to the default one. + */ +export const resolveDevPorts = (env: NodeJS.ProcessEnv) => ({ + backendPort: assertDevPort(env, 'N8N_PORT', DEFAULT_BACKEND_PORT), + editorPort: assertDevPort(env, 'N8N_EDITOR_PORT', DEFAULT_EDITOR_PORT), +}); + +/** + * Dev-server topology, kept out of the `serve` script so the env vars work on + * Windows too (`cross-env` cannot expand `${N8N_PORT:-5678}`). + * + * `apply: 'serve'` skips builds; the mode/isPreview check skips `vite preview` + * (serve/production) and vitest (serve/test). Builds must leave + * VUE_APP_URL_BASE_API unset so the app falls back to window.BASE_PATH. + */ +export const devServerPlugin = (env: NodeJS.ProcessEnv): Plugin => ({ + name: 'n8n-dev-server-topology', + apply: 'serve', + config: (_config, { mode, isPreview }) => { + if (mode !== 'development' || isPreview) return; + + const { backendPort, editorPort } = resolveDevPorts(env); + + // Vite's loadEnv reads VUE_* straight out of process.env and runs after + // this hook, so the assignment still reaches import.meta.env. + // Truthiness, not ??=: an explicitly empty value counts as unset. + if (!env.VUE_APP_URL_BASE_API) { + env.VUE_APP_URL_BASE_API = `http://localhost:${backendPort}/`; + } + + return { server: { host: '0.0.0.0', port: editorPort, strictPort: true } }; + }, +}); diff --git a/packages/frontend/editor-ui/vite/dev-ports.test.ts b/packages/frontend/editor-ui/vite/dev-ports.test.ts new file mode 100644 index 00000000000..437cf5ce721 --- /dev/null +++ b/packages/frontend/editor-ui/vite/dev-ports.test.ts @@ -0,0 +1,88 @@ +import type { ConfigEnv, UserConfig } from 'vite'; +import { describe, expect, it } from 'vitest'; + +import { devServerPlugin, resolveDevPorts } from './dev-ports.mjs'; + +const runConfigHook = (env: NodeJS.ProcessEnv, configEnv: ConfigEnv) => { + const plugin = devServerPlugin(env); + const hook = plugin.config as (c: UserConfig, e: ConfigEnv) => UserConfig | undefined; + return hook({}, configEnv); +}; + +const DEV: ConfigEnv = { command: 'serve', mode: 'development' }; +const VITEST: ConfigEnv = { command: 'serve', mode: 'test' }; +const PREVIEW: ConfigEnv = { command: 'serve', mode: 'production', isPreview: true }; + +describe('resolveDevPorts', () => { + it.each([ + ['unset', {}, 5678, 8080], + ['empty', { N8N_PORT: '', N8N_EDITOR_PORT: '' }, 5678, 8080], + ['set', { N8N_PORT: '5699', N8N_EDITOR_PORT: '8082' }, 5699, 8082], + ])('resolves %s ports', (_label, env, backendPort, editorPort) => { + expect(resolveDevPorts(env)).toEqual({ backendPort, editorPort }); + }); + + it.each(['abc', '0', '70000', '5699.5', '-1'])('rejects the malformed port %s', (value) => { + expect(() => resolveDevPorts({ N8N_EDITOR_PORT: value })).toThrow( + `N8N_EDITOR_PORT must be a port number, got: ${value}`, + ); + }); + + it('names the offending variable', () => { + expect(() => resolveDevPorts({ N8N_PORT: 'abc' })).toThrow(/^N8N_PORT must be/); + }); +}); + +describe('devServerPlugin', () => { + it('never applies to builds', () => { + expect(devServerPlugin({}).apply).toBe('serve'); + }); + + it('binds the editor port and derives the REST base from N8N_PORT', () => { + const env: NodeJS.ProcessEnv = { N8N_PORT: '5699', N8N_EDITOR_PORT: '8082' }; + + expect(runConfigHook(env, DEV)).toEqual({ + server: { host: '0.0.0.0', port: 8082, strictPort: true }, + }); + expect(env.VUE_APP_URL_BASE_API).toBe('http://localhost:5699/'); + }); + + it('falls back to the default ports', () => { + const env: NodeJS.ProcessEnv = {}; + + expect(runConfigHook(env, DEV)?.server).toMatchObject({ port: 8080 }); + expect(env.VUE_APP_URL_BASE_API).toBe('http://localhost:5678/'); + }); + + it('keeps an explicitly set REST base URL', () => { + const env: NodeJS.ProcessEnv = { VUE_APP_URL_BASE_API: 'https://tunnel.example/' }; + + runConfigHook(env, DEV); + + expect(env.VUE_APP_URL_BASE_API).toBe('https://tunnel.example/'); + }); + + it('treats an empty REST base URL as unset', () => { + const env: NodeJS.ProcessEnv = { VUE_APP_URL_BASE_API: '' }; + + runConfigHook(env, DEV); + + expect(env.VUE_APP_URL_BASE_API).toBe('http://localhost:5678/'); + }); + + // A build that inherits VUE_APP_URL_BASE_API would ship a localhost REST base + // instead of falling back to window.BASE_PATH, and vitest must not bind a port. + it.each([ + ['vitest', VITEST], + ['preview', PREVIEW], + ])('stays out of the way of %s', (_label, configEnv) => { + const env: NodeJS.ProcessEnv = { N8N_EDITOR_PORT: '8082' }; + + expect(runConfigHook(env, configEnv)).toBeUndefined(); + expect(env.VUE_APP_URL_BASE_API).toBeUndefined(); + }); + + it('does not validate ports outside the dev server', () => { + expect(() => runConfigHook({ N8N_EDITOR_PORT: 'abc' }, VITEST)).not.toThrow(); + }); +}); diff --git a/packages/testing/playwright/README.md b/packages/testing/playwright/README.md index 9bf1fbfd27b..6e269548399 100644 --- a/packages/testing/playwright/README.md +++ b/packages/testing/playwright/README.md @@ -108,7 +108,8 @@ situations where `test:local`'s defaults aren't enough: Pin a port with `N8N_BASE_URL=http://localhost:5680 …` when you need a stable URL for browser inspection. - **Throwaway `N8N_USER_FOLDER`** under the OS temp dir (cleaned up on exit). - Its `database.sqlite` is fully isolated from your local `~/.n8n` install. + n8n creates `.n8n/` (sqlite DB, encryption key) inside it, fully isolated + from your local `~/.n8n` install. - **Container-only tests included.** `@capability:*` / `@licensed` / `@db:reset` tests are picked up by the local `e2e` project. Their fixtures are responsible for detecting the missing container and skipping or falling diff --git a/packages/testing/playwright/global-teardown.ts b/packages/testing/playwright/global-teardown.ts index 18e0b37a401..9f292c1ead8 100644 --- a/packages/testing/playwright/global-teardown.ts +++ b/packages/testing/playwright/global-teardown.ts @@ -1,9 +1,22 @@ import { execSync } from 'child_process'; +import { getBackendUrl, getFrontendUrl } from './utils/url-helper'; + function globalTeardown() { console.log('🧹 Starting global teardown...'); - const ports = [5678, 8080]; + // Ports this run actually used (they may differ from the defaults, e.g. the + // alt-port dev-server smoke runs the backend on 5699). A portless URL means + // 80/443, never a dev server this run started. Killing nothing beats falling + // back to the defaults, which are most likely the developer's own servers. + const ports = [ + ...new Set( + [getBackendUrl(), getFrontendUrl()] + .filter((url): url is string => !!url && URL.canParse(url)) + .map((url) => new URL(url).port) + .filter(Boolean), + ), + ]; for (const port of ports) { try { diff --git a/packages/testing/playwright/package.json b/packages/testing/playwright/package.json index ee52b745450..d801a65276a 100644 --- a/packages/testing/playwright/package.json +++ b/packages/testing/playwright/package.json @@ -4,6 +4,7 @@ "scripts": { "dev": "N8N_BASE_URL=http://localhost:5678 N8N_EDITOR_URL=http://localhost:8080 RESET_E2E_DB=true playwright test --project=e2e", "test:dev-server-smoke": "N8N_BASE_URL=http://localhost:5678 N8N_EDITOR_URL=http://localhost:8080 RESET_E2E_DB=true playwright test --project=dev-server-smoke", + "test:dev-server-smoke:alt-port": "N8N_BASE_URL=http://localhost:5699 N8N_EDITOR_URL=http://localhost:8082 N8N_TEST_ENV='{\"N8N_RUNNERS_BROKER_PORT\":\"5701\"}' RESET_E2E_DB=true playwright test --project=dev-server-smoke", "test:all": "playwright test", "test:local": "N8N_BASE_URL=http://localhost:5680 RESET_E2E_DB=true playwright test --project=e2e", "test:local:isolated": "node scripts/run-local-isolated.mjs", diff --git a/packages/testing/playwright/playwright.config.ts b/packages/testing/playwright/playwright.config.ts index 6ea83bfb130..e8bdbc28b60 100644 --- a/packages/testing/playwright/playwright.config.ts +++ b/packages/testing/playwright/playwright.config.ts @@ -15,7 +15,13 @@ const IS_DEV = !!process.env.N8N_EDITOR_URL; const MACBOOK_WINDOW_SIZE = { width: 1536, height: 960 }; -const USER_FOLDER = path.join(os.tmpdir(), `n8n-main-${Date.now()}`); +// Sticky across processes: workers re-import this config, and Date.now() +// would give each of them a different path than the one the webServer got. +// Only workers honor the inherited value; the main process always mints a +// fresh folder so a stale shell-exported variable can't leak in. +const USER_FOLDER = + (process.env.TEST_WORKER_INDEX !== undefined ? process.env.N8N_TEST_USER_FOLDER : undefined) ?? + path.join(os.tmpdir(), `n8n-main-${Date.now()}`); // Helper to get environment variables from N8N_TEST_ENV const getTestEnv = () => { @@ -52,6 +58,16 @@ const webServer: PlaywrightTestConfig['webServer'] = []; const SKIP_WEB_SERVER = process.env.PLAYWRIGHT_SKIP_WEBSERVER === 'true'; if (BACKEND_URL && !SKIP_WEB_SERVER) { + // Expose the user folder to specs (the dev-server smoke asserts the sqlite DB + // is created inside it). Only meaningful when this run starts the backend + // itself; a reused server writes to wherever it was already pointed. + // N8N_TEST_ENV may override N8N_USER_FOLDER (spread below wins), so export + // the folder the backend will actually use. + if (IS_DEV) { + const envUserFolder: unknown = getTestEnv().N8N_USER_FOLDER; + process.env.N8N_TEST_USER_FOLDER = + typeof envUserFolder === 'string' ? envUserFolder : USER_FOLDER; + } webServer.push({ command: 'cd .. && pnpm start', url: `${BACKEND_URL}/favicon.ico`, @@ -81,7 +97,10 @@ if (IS_DEV && FRONTEND_URL) { reuseExistingServer: IS_DEV ? false : true, env: { E2E_TESTS: 'true', - N8N_PORT: getPortFromUrl(FRONTEND_URL), + // The dev frontend derives its backend URL (BASE_PATH + REST base) + // from N8N_PORT, so pass the backend's port, not the frontend's. + ...(BACKEND_URL ? { N8N_PORT: getPortFromUrl(BACKEND_URL) } : {}), + N8N_EDITOR_PORT: getPortFromUrl(FRONTEND_URL), ...getTestEnv(), }, }); diff --git a/packages/testing/playwright/scripts/run-local-isolated.mjs b/packages/testing/playwright/scripts/run-local-isolated.mjs index 6e614fd912e..6393b8b3bd5 100644 --- a/packages/testing/playwright/scripts/run-local-isolated.mjs +++ b/packages/testing/playwright/scripts/run-local-isolated.mjs @@ -9,8 +9,8 @@ * `5678`/`5679`. Pin a port with `N8N_BASE_URL=http://localhost:5680 …` * when you need a stable URL for browser inspection. * - **Throwaway `N8N_USER_FOLDER`** under the OS temp dir (cleaned up on - * exit). Its `database.sqlite` is fully isolated from your local - * `~/.n8n` install. + * exit). n8n creates `.n8n/` (sqlite DB, encryption key) inside it, fully + * isolated from your local `~/.n8n` install. * - **Self-managed n8n process** with a real readiness check against * `/rest/e2e/reset` (Playwright's default `webServer` favicon check is * racy with slower module startups). Sets `PLAYWRIGHT_SKIP_WEBSERVER=true` @@ -76,8 +76,8 @@ if (process.env.N8N_BASE_URL) { backendUrl = `http://localhost:${port}`; } -// Throwaway `~/.n8n`-equivalent. SQLite lives at `${userFolder}/database.sqlite`, -// so this also isolates the DB from any local n8n install. +// Throwaway home-dir stand-in. n8n creates `.n8n/` (sqlite DB, encryption +// key) inside it, so this also isolates the DB from any local n8n install. const userFolder = mkdtempSync(path.join(os.tmpdir(), 'n8n-test-isolated-')); // Caller-supplied n8n env (same convention as `pnpm test:local`). diff --git a/packages/testing/playwright/tests/dev-server-smoke/dev-server-boots.spec.ts b/packages/testing/playwright/tests/dev-server-smoke/dev-server-boots.spec.ts index f013f0ed32a..82f727eea55 100644 --- a/packages/testing/playwright/tests/dev-server-smoke/dev-server-boots.spec.ts +++ b/packages/testing/playwright/tests/dev-server-smoke/dev-server-boots.spec.ts @@ -1,6 +1,6 @@ import type { ConsoleMessage, Page } from '@playwright/test'; -import { test } from '../../fixtures/base'; +import { expect, test } from '../../fixtures/base'; /** * Smoke tests that catch app-wide regressions where the editor-ui fails to boot — @@ -125,6 +125,22 @@ test.describe( }); }); + // The dev frontend derives its REST base from N8N_PORT. "Boots cleanly" is + // not enough on its own: on the default ports a hardcoded 5678 would be + // indistinguishable from correct derivation, so assert the origin directly. + test('REST calls go to the configured backend', async ({ n8n }) => { + const restOrigins = new Set(); + n8n.page.on('request', (request) => { + const url = new URL(request.url()); + if (url.pathname.startsWith('/rest/')) restOrigins.add(url.origin); + }); + + await n8n.start.fromHome(); + + const backendOrigin = new URL(process.env.N8N_BASE_URL!).origin; + expect([...restOrigins]).toEqual([backendOrigin]); + }); + test('blank canvas boots cleanly', async ({ n8n }) => { await navigateAndAssertNoErrors(n8n.page, 'blank-canvas', async () => { await n8n.start.fromBlankCanvas(); diff --git a/packages/testing/playwright/tests/dev-server-smoke/user-folder.spec.ts b/packages/testing/playwright/tests/dev-server-smoke/user-folder.spec.ts new file mode 100644 index 00000000000..770d5ca469b --- /dev/null +++ b/packages/testing/playwright/tests/dev-server-smoke/user-folder.spec.ts @@ -0,0 +1,39 @@ +import { existsSync } from 'fs'; +import path from 'path'; + +import { expect, test } from '../../fixtures/base'; + +/** + * Asserts that the backend started by this run respects N8N_USER_FOLDER: the + * sqlite database must be created inside the run's generated user folder (see + * USER_FOLDER in playwright.config.ts), not in the default ~/.n8n. Guards the + * smoke suite against silently polluting the developer's own install. + * + * Playwright polls the backend's favicon and global-setup resets its database + * before any test runs, so the file exists by now without visiting the app. + */ +test.describe( + 'Dev-server user folder', + { + annotation: [ + { type: 'owner', description: 'Catalysts' }, + { + type: 'description', + description: + 'Verifies the dev backend creates its sqlite database inside the N8N_USER_FOLDER configured for the run.', + }, + ], + }, + () => { + test('sqlite database is created in the configured user folder', () => { + // The smoke scripts always manage the webServer, so a missing value + // means the config did not export the user folder: fail, don't skip. + const userFolder = process.env.N8N_TEST_USER_FOLDER; + expect(userFolder, 'playwright.config.ts must export N8N_TEST_USER_FOLDER').toBeTruthy(); + + // n8n creates `.n8n/` inside N8N_USER_FOLDER (see getN8nFolder in @n8n/config). + const dbPath = path.join(userFolder!, '.n8n', 'database.sqlite'); + expect(existsSync(dbPath), `expected sqlite DB at ${dbPath}`).toBe(true); + }); + }, +);