first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,42 @@
var File = function(url, object){
File.list = Array.isArray(File.list)? File.list : [];
File.progress = File.progress || 0;
this.progress = 0;
this.object = object;
this.url = url;
};
File.indexOf = function(term){
for(var index in File.list){
var file = File.list[index];
if (file.equals(term) || file.url === term || file.object === term) {
return index;
}
}
return -1;
};
File.find = function(term){
var index = File.indexOf(term);
return ~index && File.list[index];
};
File.prototype.equals = function(file){
var isFileType = file instanceof File;
return isFileType && this.url === file.url && this.object === file.object;
};
File.prototype.save = function(update){
update = typeof update === 'undefined'? true : update;
if(Array.isArray(File.list)){
var index = File.indexOf(this);
if(~index && update) {
File.list[index] = this;
console.warn('File `%s` has been loaded before and updated now for: %O.', this.url, this);
}else File.list.push(this);
console.log(File.list)
}else{
File.list = [this];
}
return this;
};
@@ -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"
}
]
}
@@ -0,0 +1,83 @@
{
"name": "My workflow 8",
"nodes": [
{
"parameters": {
"operation": "getAllPeople",
"limit": 10
},
"id": "39cd80ce-5a8f-4339-b3d5-c4af969dd330",
"name": "Customer Datastore (n8n training)",
"type": "n8n-nodes-base.n8nTrainingCustomerDatastore",
"typeVersion": 1,
"position": [940, 680]
},
{
"parameters": {
"values": {
"number": [
{
"name": "objectValue.prop1",
"value": 123
}
],
"string": [
{
"name": "objectValue.prop2",
"value": "someText"
}
]
},
"options": {
"dotNotation": true
}
},
"id": "6e4490f6-ba95-4400-beec-2caefdd4895a",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [1300, 680]
},
{
"parameters": {},
"id": "58512a93-dabf-4584-817f-27c608c1bdd5",
"name": "When clicking Execute workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [720, 680]
}
],
"pinData": {},
"connections": {
"Customer Datastore (n8n training)": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
},
"When clicking Execute workflow": {
"main": [
[
{
"node": "Customer Datastore (n8n training)",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "4a4f292a-92be-427c-848a-9582527f5ed3",
"id": "8",
"meta": {
"instanceId": "032eceae7493054b723340499be69ecbf4cbe28a7ec6df676b759000750b968d"
},
"tags": []
}
@@ -0,0 +1,300 @@
import type { CurrentsFixtures, CurrentsWorkerFixtures } from '@currents/playwright';
import { fixtures as currentsFixtures } from '@currents/playwright';
import { test as base, expect, request } from '@playwright/test';
import type { ServiceHelpers } from 'n8n-containers/services/types';
import type { N8NConfig, N8NStack } from 'n8n-containers/stack';
import { createN8NStack } from 'n8n-containers/stack';
import { CAPABILITIES, type Capability } from './capabilities';
import { consoleErrorFixtures } from './console-error-monitor';
import { N8N_AUTH_COOKIE } from '../config/constants';
import { setupDefaultInterceptors } from '../config/intercepts';
import { observabilityFixtures, type ObservabilityTestFixtures } from '../fixtures/observability';
import { n8nPage } from '../pages/n8nPage';
import { ApiHelpers } from '../services/api-helper';
import { TestError, type TestRequirements } from '../Types';
import { setupTestRequirements } from '../utils/requirements';
import { getBackendUrl, getFrontendUrl } from '../utils/url-helper';
type TestFixtures = {
n8n: n8nPage;
api: ApiHelpers;
baseURL: string;
setupRequirements: (requirements: TestRequirements) => Promise<void>;
/** Type-safe service helpers (mailpit, gitea, proxy, observability, etc.) */
services: ServiceHelpers;
/**
* Direct URLs to each main instance (bypasses load balancer).
* Only available in container mode with multi-main setup.
* Index 0 = main-1, Index 1 = main-2, etc.
*/
mainUrls: string[];
/**
* Create an API helper for a specific main instance (bypasses load balancer).
* Useful for multi-main testing scenarios.
* @param mainIndex - 0-based index of the main (0 = main-1, 1 = main-2, etc.)
*/
createApiForMain: (mainIndex: number) => Promise<ApiHelpers>;
};
type WorkerFixtures = {
n8nUrl: string;
backendUrl: string;
frontendUrl: string;
dbSetup: undefined;
n8nContainer: N8NStack;
capability?: CapabilityOption;
};
type CapabilityOption = Capability | N8NConfig;
type ProjectUse = { containerConfig?: N8NConfig };
export const test = base.extend<
TestFixtures & CurrentsFixtures & ObservabilityTestFixtures,
WorkerFixtures & CurrentsWorkerFixtures
>({
...currentsFixtures.baseFixtures,
...currentsFixtures.coverageFixtures,
...currentsFixtures.actionFixtures,
...observabilityFixtures,
...consoleErrorFixtures,
// Option for test.use({ capability: 'proxy' }) - transformed into N8NStack by n8nContainer
capability: [undefined, { scope: 'worker', option: true }],
// Creates container from: project.containerConfig (base) + capability (override)
// When N8N_BASE_URL is set, skips container creation for local testing
n8nContainer: [
async ({ capability }, use, workerInfo) => {
if (getBackendUrl()) {
await use(null!);
return;
}
const { containerConfig: base = {} } = workerInfo.project.use as ProjectUse;
const override: N8NConfig = !capability
? {}
: typeof capability === 'string'
? CAPABILITIES[capability]
: capability;
const config: N8NConfig = {
...base,
...override,
services: [...new Set([...(base.services ?? []), ...(override.services ?? [])])],
env: { ...base.env, ...override.env, E2E_TESTS: 'true', N8N_RESTRICT_FILE_ACCESS_TO: '' },
};
const container = await createN8NStack(config);
await use(container);
if (process.env.N8N_CONTAINERS_KEEPALIVE === 'true') {
console.log('\n=== KEEPALIVE: Containers left running for debugging ===');
console.log(` URL: ${container.baseUrl}`);
console.log(` Project: ${container.projectName}`);
console.log(' Cleanup: pnpm --filter n8n-containers stack:clean:all');
console.log('=========================================================\n');
return;
}
await container.stop();
},
{ scope: 'worker', box: true },
],
n8nUrl: [
async ({ n8nContainer }, use) => {
const envBaseURL = process.env.N8N_BASE_URL ?? n8nContainer?.baseUrl;
await use(envBaseURL);
},
{ scope: 'worker' },
],
backendUrl: [
async ({ n8nContainer }, use) => {
const envBackendURL = getBackendUrl() ?? n8nContainer?.baseUrl;
await use(envBackendURL);
},
{ scope: 'worker' },
],
frontendUrl: [
async ({ n8nContainer }, use) => {
const envFrontendURL = getFrontendUrl() ?? n8nContainer?.baseUrl;
await use(envFrontendURL);
},
{ scope: 'worker' },
],
dbSetup: [
async ({ n8nContainer }, use) => {
if (n8nContainer) {
console.log('Resetting database for new container');
const apiContext = await request.newContext({ baseURL: n8nContainer.baseUrl });
const api = new ApiHelpers(apiContext);
await api.resetDatabase();
await apiContext.dispose();
}
await use(undefined);
},
{ scope: 'worker' },
],
baseURL: async ({ frontendUrl, dbSetup }, use) => {
void dbSetup; // Ensure dbSetup runs first
await use(frontendUrl);
},
n8n: async ({ context, backendUrl, frontendUrl }, use, testInfo) => {
await setupDefaultInterceptors(context);
const page = await context.newPage();
// Set debounce multiplier for E2E tests - 1 means normal timing (no change)
// Can be lowered (e.g. 0.5) to speed up tests, but avoid 0 as it causes race conditions
await page.addInitScript(() => {
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '1');
});
const useSeparateApiContext = backendUrl !== frontendUrl;
if (useSeparateApiContext) {
const apiContext = await request.newContext({ baseURL: backendUrl });
const api = new ApiHelpers(apiContext);
const n8nInstance = new n8nPage(page, api);
await n8nInstance.api.setupFromTags(testInfo.tags);
// Auth: no tag = owner, @auth:none = unauthenticated, @auth:member etc = specific role
const hasAuthTag = testInfo.tags.some((tag) => tag.startsWith('@auth:'));
let apiCookies = await apiContext.storageState();
let authCookie = apiCookies.cookies.find((cookie) => cookie.name === N8N_AUTH_COOKIE);
if (!hasAuthTag && !authCookie) {
await api.signin('owner');
apiCookies = await apiContext.storageState();
authCookie = apiCookies.cookies.find((cookie) => cookie.name === N8N_AUTH_COOKIE);
}
// Transfer auth cookie 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) {
await context.addCookies([
{
...authCookie,
domain: frontendUrlParsed.hostname,
path: '/',
sameSite: 'Lax',
},
]);
} else {
await context.addCookies([
{
name: authCookie.name,
value: authCookie.value,
url: frontendUrl,
path: '/',
httpOnly: authCookie.httpOnly,
secure: authCookie.secure,
sameSite: 'Lax',
},
]);
}
}
await n8nInstance.start.withProjectFeatures();
await use(n8nInstance);
await apiContext.dispose();
} else {
const n8nInstance = new n8nPage(page);
await n8nInstance.api.setupFromTags(testInfo.tags);
await n8nInstance.start.withProjectFeatures();
await use(n8nInstance);
}
},
api: async ({ backendUrl }, use, testInfo) => {
const context = await request.newContext({ baseURL: backendUrl });
const api = new ApiHelpers(context);
await api.setupFromTags(testInfo.tags);
const hasAuthTag = testInfo.tags.some((tag) => tag.startsWith('@auth:'));
const apiCookies = await context.storageState();
const authCookie = apiCookies.cookies.find((cookie) => cookie.name === N8N_AUTH_COOKIE);
if (!hasAuthTag && !authCookie) {
await api.signin('owner');
}
await use(api);
await context.dispose();
},
mainUrls: async ({ n8nContainer }, use) => {
const urls = n8nContainer?.mainUrls ?? [];
await use(urls);
},
createApiForMain: async ({ n8nContainer }, use, testInfo) => {
const contexts: Array<{ dispose: () => Promise<void> }> = [];
const createApi = async (mainIndex: number): Promise<ApiHelpers> => {
const mainUrls = n8nContainer?.mainUrls ?? [];
if (mainIndex < 0 || mainIndex >= mainUrls.length) {
throw new TestError(
`Invalid main index ${mainIndex}. Available mains: ${mainUrls.length}. ` +
'Ensure you are running in multi-main container mode.',
);
}
const context = await request.newContext({ baseURL: mainUrls[mainIndex] });
contexts.push(context);
const api = new ApiHelpers(context);
await api.setupFromTags(testInfo.tags.filter((tag) => tag.toLowerCase() !== '@db:reset'));
const hasAuthTag = testInfo.tags.some((tag) => tag.startsWith('@auth:'));
const apiCookies = await context.storageState();
const authCookie = apiCookies.cookies.find((cookie) => cookie.name === N8N_AUTH_COOKIE);
if (!hasAuthTag && !authCookie) {
await api.signin('owner');
}
return api;
};
await use(createApi);
// Cleanup all created contexts
for (const ctx of contexts) {
await ctx.dispose();
}
},
setupRequirements: async ({ n8n, context }, use) => {
const setupFunction = async (requirements: TestRequirements): Promise<void> => {
await setupTestRequirements(n8n, context, requirements);
};
await use(setupFunction);
},
services: async ({ n8nContainer }, use) => {
await use(n8nContainer.services);
},
});
export { expect };
/*
Fixture Dependency Graph:
Worker: capability + project.containerConfig → n8nContainer → [backendUrl, frontendUrl, dbSetup]
Test: frontendUrl + dbSetup → baseURL → n8n (uses backendUrl for API calls)
backendUrl → api
n8nContainer → services
services: Type-safe helpers (mailpit, gitea, proxy, observability, etc.)
n8nContainer: Container lifecycle (stop, containers, mainUrls, etc.)
*/
@@ -0,0 +1,53 @@
import type { N8NConfig } from 'n8n-containers/stack';
/**
* Capability definitions for `test.use({ capability: 'email' })`.
* Add `@capability:X` tag to tests for orchestration grouping.
*
* Maps capability names to service registry keys.
* Note: task-runner is always enabled, no capability needed.
*/
export const CAPABILITIES = {
email: { services: ['mailpit'] },
proxy: { services: ['proxy'] },
'source-control': { services: ['gitea'] },
oidc: { services: ['keycloak'] },
observability: { services: ['victoriaLogs', 'victoriaMetrics', 'vector'] },
kafka: { services: ['kafka'] },
'external-secrets': {
services: ['localstack'],
env: {
// Enable project-scoped external secrets feature at startup
// (required for secret-providers-connections API)
N8N_ENV_FEAT_EXTERNAL_SECRETS_FOR_PROJECTS: 'true',
},
},
kent: { services: ['kent'] },
'dynamic-credentials': {
services: ['keycloak'],
env: {
N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: 'true',
// Static token required to allow unauthenticated (external) requests to dynamic credential endpoints
N8N_DYNAMIC_CREDENTIALS_ENDPOINT_AUTH_TOKEN: 'e2e-test-endpoint-token',
},
},
} as const satisfies Record<string, Partial<N8NConfig>>;
export type Capability = keyof typeof CAPABILITIES;
/**
* Infrastructure modes (`@mode:X` tags). Most tests run against ALL modes via projects.
* Use @mode:X only for tests requiring specific infrastructure.
*/
export const INFRASTRUCTURE_MODES = ['postgres', 'queue', 'multi-main'] as const;
/**
* Tests requiring enterprise license features (`@licensed` tag).
* These tests only run in container mode where a license file is available.
* Use for tests that interact with enterprise-only API endpoints (log streaming, SSO, etc.)
*/
export const LICENSED_TAG = 'licensed';
// Used by playwright-projects.ts to filter container-only tests in local mode
export const CONTAINER_ONLY_CAPABILITIES = Object.keys(CAPABILITIES) as Capability[];
export const CONTAINER_ONLY_MODES = INFRASTRUCTURE_MODES;
@@ -0,0 +1,82 @@
import type { BrowserContext, ConsoleMessage, TestInfo } from '@playwright/test';
interface ConsoleError {
type: string;
text: string;
location: string;
timestamp: number;
}
/**
* Monitors browser context for console errors.
* Attaches diagnostic info to test results when errors occur.
* No-op when no errors are detected.
*/
class ConsoleErrorMonitor {
private errors: ConsoleError[] = [];
private readonly listener = (message: ConsoleMessage) => {
if (message.type() === 'error') {
this.errors.push({
type: message.type(),
text: message.text(),
location: message.location().url,
timestamp: Date.now(),
});
}
};
attach(context: BrowserContext): void {
context.on('console', this.listener);
}
detach(context: BrowserContext): void {
context.off('console', this.listener);
}
hasErrors(): boolean {
return this.errors.length > 0;
}
getErrors(): ConsoleError[] {
return this.errors;
}
}
/**
* Console error monitor fixtures for capturing browser errors.
* Spread into test.extend() to enable monitoring.
*/
export const consoleErrorFixtures = {
_consoleErrorMonitor: [
async (
{ context }: { context: BrowserContext },
use: (monitor: ConsoleErrorMonitor) => Promise<void>,
testInfo: TestInfo,
) => {
const monitor = new ConsoleErrorMonitor();
monitor.attach(context);
await use(monitor);
monitor.detach(context);
// Attach diagnostics if errors occurred
if (monitor.hasErrors()) {
await testInfo.attach('console-errors', {
body: JSON.stringify(
{
errors: monitor.getErrors(),
testTitle: testInfo.title,
project: testInfo.project.name,
},
null,
2,
),
contentType: 'application/json',
});
}
},
{ auto: true },
],
};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,103 @@
import type { Fixtures, TestInfo } from '@playwright/test';
import type { N8NStack } from 'n8n-containers/stack';
export type ObservabilityTestFixtures = {
autoAttachLogs: undefined;
};
export type ObservabilityWorkerFixtures = {
n8nContainer: N8NStack;
};
async function attachLogsOnFailure(
stack: N8NStack,
testInfo: TestInfo,
options: { lookbackMinutes?: number } = {},
): Promise<void> {
const obs = stack.services?.observability;
if (!obs) return;
const lookback = options.lookbackMinutes ?? 5;
try {
const logs = await obs.logs.query('*', {
limit: 10000,
start: `${lookback}m`,
});
if (logs.length === 0) return;
const groupedLogs = logs.reduce<Record<string, typeof logs>>((acc, log) => {
const container = log.container_name ?? 'unknown';
acc[container] ??= [];
acc[container].push(log);
return acc;
}, {});
for (const containerLogs of Object.values(groupedLogs)) {
containerLogs.sort((a, b) => (a._time ?? '').localeCompare(b._time ?? ''));
}
const formattedLogs = Object.entries(groupedLogs)
.sort(([a], [b]) => a.localeCompare(b))
.map(([container, containerLogs]) => {
const logLines = containerLogs.map((log) => `[${log._time}] ${log.message}`).join('\n');
return `=== ${container} ===\n${logLines}`;
})
.join('\n\n');
await testInfo.attach('container-logs', {
body: formattedLogs,
contentType: 'text/plain',
});
const jsonLinesExport = logs.map((log) => JSON.stringify(log)).join('\n');
await testInfo.attach('victoria-logs-export.jsonl', {
body: jsonLinesExport,
contentType: 'application/x-ndjson',
});
} catch (error) {
console.warn('Failed to collect container logs:', error);
}
}
async function attachMetricsOnFailure(stack: N8NStack, testInfo: TestInfo): Promise<void> {
const obs = stack.services?.observability;
if (!obs) return;
try {
const metricsExport = await obs.metrics.exportAll();
if (!metricsExport.trim()) return;
await testInfo.attach('victoria-metrics-export.jsonl', {
body: metricsExport,
contentType: 'application/x-ndjson',
});
} catch (error) {
console.warn('Failed to export metrics:', error);
}
}
/**
* Auto-attaches container logs and metrics on test failure.
* Import exports locally with scripts/import-victoria-data.mjs
*/
export const observabilityFixtures: Fixtures<
ObservabilityTestFixtures,
ObservabilityWorkerFixtures
> = {
autoAttachLogs: [
async ({ n8nContainer }, use, testInfo) => {
await use(undefined);
if (testInfo.status !== testInfo.expectedStatus && n8nContainer?.services?.observability) {
await Promise.all([
attachLogsOnFailure(n8nContainer, testInfo),
attachMetricsOnFailure(n8nContainer, testInfo),
]);
}
},
{ auto: true },
],
};
@@ -0,0 +1,35 @@
{
"id": 200,
"planId": 1,
"pruneExecutionsInterval": 168,
"monthlyExecutionsLimit": 1000,
"activeWorkflowsLimit": 20,
"credentialsLimit": 100,
"supportTier": "community",
"displayName": "Trial",
"userIsTrialing": true,
"enabledFeatures": ["userManagement", "advancedExecutionFilters", "sharing"],
"licenseFeatures": {
"feat:sharing": true,
"feat:advancedExecutionFilters": true,
"feat:apiDisabled": true,
"quota:users": -1,
"quota:maxVariables": -1,
"feat:variables": true
},
"metadata": {
"version": "v1",
"group": "trial",
"slug": "trial-2",
"trial": {
"length": 14,
"gracePeriod": 3
}
},
"bannerConfig": {
"timeLeft": {},
"showExecutions": true,
"dismissible": true
},
"expirationDate": "2023-08-30T15:47:27.611Z"
}