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,10 @@
import type { Locator } from '@playwright/test';
/**
* Returns a locator for a specific element by index, or the original locator if no index is provided.
* Without an index, Playwright throws an error when multiple matching elements are found.
* @see https://playwright.dev/docs/locators#strictness
*/
export function locatorByIndex(locator: Locator, index?: number) {
return typeof index === 'number' ? locator.nth(index) : locator;
}
@@ -0,0 +1,48 @@
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;
}
/**
* Finds a folder root by searching upwards for a marker folder named 'packages'.
* @returns The absolute path to the folder root.
*/
export function findPackagesRoot(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 packages 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);
}
@@ -0,0 +1,213 @@
import type { Page, TestInfo } from '@playwright/test';
import type { MetricsHelper } from 'n8n-containers';
const HEAP_USED_QUERY = 'n8n_nodejs_heap_size_used_bytes / 1024 / 1024';
const HEAP_TOTAL_QUERY = 'n8n_nodejs_heap_size_total_bytes / 1024 / 1024';
const RSS_QUERY = 'n8n_process_resident_memory_bytes / 1024 / 1024';
const PSS_QUERY = 'n8n_process_pss_bytes / 1024 / 1024';
export async function measurePerformance(
page: Page,
actionName: string,
actionFn: () => Promise<void>,
): Promise<number> {
await page.evaluate((name) => performance.mark(`${name}-start`), actionName);
await actionFn();
return await page.evaluate((name) => {
performance.mark(`${name}-end`);
performance.measure(name, `${name}-start`, `${name}-end`);
const measure = performance.getEntriesByName(name)[0] as PerformanceMeasure;
return measure.duration;
}, actionName);
}
export async function getAllPerformanceMetrics(page: Page) {
return await page.evaluate(() => {
const metrics: Record<string, number> = {};
const measures = performance.getEntriesByType('measure') as PerformanceMeasure[];
measures.forEach((m) => (metrics[m.name] = m.duration));
return metrics;
});
}
/** Attach a performance metric for collection by the metrics reporter */
export async function attachMetric(
testInfo: TestInfo,
metricName: string,
value: number,
unit?: string,
): Promise<void> {
await testInfo.attach(`metric:${metricName}`, {
body: JSON.stringify({ value, unit }),
});
}
export interface StableHeapOptions {
maxWaitMs?: number;
checkIntervalMs?: number;
thresholdMB?: number;
stableReadingsRequired?: number;
logGC?: boolean;
}
export interface StableHeapResult {
heapUsedMB: number;
heapTotalMB: number;
rssMB: number;
pssMB: number | null;
nonHeapOverheadMB: number;
stabilizationTimeMs: number;
readingsCount: number;
}
/**
* Trigger GC and wait for heap memory to stabilize.
* Collects RSS, PSS, and heap total samples during the stabilization window
* and returns median values to reduce point-in-time noise.
*/
export async function getStableHeap(
baseUrl: string,
metrics: MetricsHelper,
options: StableHeapOptions = {},
): Promise<StableHeapResult> {
const {
maxWaitMs = 60000,
checkIntervalMs = 5000,
thresholdMB = 2,
stableReadingsRequired = 2,
logGC = true,
} = options;
await triggerGC(baseUrl, logGC);
return await waitForStableMemory(metrics, {
maxWaitMs,
checkIntervalMs,
thresholdMB,
stableReadingsRequired,
});
}
async function triggerGC(baseUrl: string, log: boolean): Promise<void> {
const response = await fetch(`${baseUrl}/rest/e2e/gc`, { method: 'POST' });
if (!response.ok) {
throw new Error(`GC endpoint returned ${response.status}: ${response.statusText}`);
}
const result = (await response.json()) as { data?: { success: boolean; message: string } };
if (!result.data?.success) {
throw new Error(`GC failed: ${result.data?.message ?? 'Unknown error'}`);
}
if (log) {
console.log(`[GC] ${result.data.message}`);
}
}
interface StabilizationConfig {
maxWaitMs: number;
checkIntervalMs: number;
thresholdMB: number;
stableReadingsRequired: number;
}
interface MemorySamples {
heapTotal: number[];
rss: number[];
pss: number[];
}
function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
async function collectAdditionalSamples(
metrics: MetricsHelper,
samples: MemorySamples,
): Promise<void> {
try {
const results = await Promise.all([
metrics.query(HEAP_TOTAL_QUERY),
metrics.query(RSS_QUERY),
metrics.query(PSS_QUERY),
]);
if (results[0]?.[0]) samples.heapTotal.push(results[0][0].value);
if (results[1]?.[0]) samples.rss.push(results[1][0].value);
if (results[2]?.[0]) samples.pss.push(results[2][0].value);
} catch {
// Non-critical, skip this sample
}
}
async function waitForStableMemory(
metrics: MetricsHelper,
config: StabilizationConfig,
): Promise<StableHeapResult> {
const { maxWaitMs, checkIntervalMs, thresholdMB, stableReadingsRequired } = config;
const startTime = Date.now();
let lastValue = 0;
let stableCount = 0;
let readingsCount = 0;
const samples: MemorySamples = { heapTotal: [], rss: [], pss: [] };
while (Date.now() - startTime < maxWaitMs) {
const result = await metrics.waitForMetric(HEAP_USED_QUERY, {
timeoutMs: checkIntervalMs,
intervalMs: 1000,
});
if (result) {
readingsCount++;
const currentValue = result.value;
await collectAdditionalSamples(metrics, samples);
const delta = Math.abs(currentValue - lastValue);
if (lastValue > 0 && delta < thresholdMB) {
stableCount++;
if (stableCount >= stableReadingsRequired) {
const stabilizationTimeMs = Date.now() - startTime;
const heapUsedMB = currentValue;
const heapTotalMB = median(samples.heapTotal);
const rssMB = median(samples.rss);
const pssMB = samples.pss.length > 0 ? median(samples.pss) : null;
// Can theoretically go negative if RSS/heapTotal medians come from slightly
// different sample windows. A negative value would indicate a measurement
// timing issue — don't clamp to 0, surface it for investigation.
const nonHeapOverheadMB = rssMB - heapTotalMB;
console.log(
`[STABILIZATION] Memory stabilized after ${stabilizationTimeMs}ms (${readingsCount} readings)\n` +
` Heap Used: ${heapUsedMB.toFixed(2)} MB\n` +
` Heap Total: ${heapTotalMB.toFixed(2)} MB (median of ${samples.heapTotal.length})\n` +
` RSS: ${rssMB.toFixed(2)} MB (median of ${samples.rss.length})\n` +
` PSS: ${pssMB?.toFixed(2) ?? 'N/A'} MB${pssMB !== null ? ` (median of ${samples.pss.length})` : ''}\n` +
` Non-Heap Overhead: ${nonHeapOverheadMB.toFixed(2)} MB`,
);
return {
heapUsedMB,
heapTotalMB,
rssMB,
pssMB,
nonHeapOverheadMB,
stabilizationTimeMs,
readingsCount,
};
}
} else {
stableCount = 0;
}
lastValue = currentValue;
}
await new Promise((resolve) => setTimeout(resolve, checkIntervalMs));
}
throw new Error(
`Memory did not stabilize within ${maxWaitMs}ms. ` +
`Last: ${lastValue.toFixed(2)} MB (${readingsCount} readings)`,
);
}
@@ -0,0 +1,70 @@
import type { BrowserContext } from '@playwright/test';
import { setContextSettings } from '../config/intercepts';
import type { n8nPage } from '../pages/n8nPage';
import { TestError, type TestRequirements } from '../Types';
export async function setupTestRequirements(
n8n: n8nPage,
context: BrowserContext,
requirements: TestRequirements,
): Promise<void> {
// 0. Setup browser storage before creating a new page
if (requirements.storage) {
await context.addInitScript((storage) => {
// Set localStorage items
for (const [key, value] of Object.entries(storage)) {
window.localStorage.setItem(key, value);
}
}, requirements.storage);
}
// 1. Setup frontend settings override
if (requirements.config?.settings) {
// Store settings for this context
setContextSettings(context, requirements.config.settings);
}
// 2. Setup feature flags
if (requirements.config?.features) {
for (const [feature, enabled] of Object.entries(requirements.config.features)) {
if (enabled) {
await n8n.api.enableFeature(feature);
} else {
await n8n.api.disableFeature(feature);
}
}
}
// 3. Setup API intercepts
if (requirements.intercepts) {
for (const config of Object.values(requirements.intercepts)) {
await n8n.page.route(config.url, async (route) => {
await route.fulfill({
status: config.status ?? 200,
contentType: config.contentType ?? 'application/json',
body:
typeof config.response === 'string' ? config.response : JSON.stringify(config.response),
});
});
}
}
// 4. Setup workflows
if (requirements.workflow) {
const entries =
typeof requirements.workflow === 'string'
? [[requirements.workflow, requirements.workflow]]
: Object.entries(requirements.workflow);
for (const [name, workflowData] of entries) {
try {
// Import workflow using the n8n page object
await n8n.navigate.toWorkflow('new');
await n8n.canvas.importWorkflow(name, workflowData);
} catch (error) {
throw new TestError(`Failed to create workflow ${name}: ${String(error)}`);
}
}
}
}
@@ -0,0 +1,32 @@
/**
* Retries the given assertion until it passes or the timeout is reached
*
* @example
* await retryUntil(
* () => expect(service.someState).toBe(true)
* );
*/
export const retryUntil = async (
assertion: () => Promise<void> | void,
{ intervalMs = 200, timeoutMs = 5000 } = {},
) => {
return await new Promise((resolve, reject) => {
const startTime = Date.now();
const tryAgain = () => {
setTimeout(async () => {
try {
resolve(await assertion());
} catch (error) {
if (Date.now() - startTime > timeoutMs) {
reject(error instanceof Error ? error : new Error(String(error)));
} else {
tryAgain();
}
}
}, intervalMs);
};
tryAgain();
});
};
@@ -0,0 +1,114 @@
import type { GitCommitInfo, SourceControlledFile } from '@n8n/api-types';
import { expect } from '@playwright/test';
import type { GiteaHelper } from 'n8n-containers';
import type { n8nPage } from '../pages/n8nPage';
async function waitForCommitOnGitea(
gitea: GiteaHelper,
repoName: string,
commitHash: string,
timeout = 10000,
pollInterval = 500,
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const exists = await gitea.commitExists(repoName, commitHash);
if (exists) {
return;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
throw new Error(`Commit ${commitHash} not found on Gitea repo ${repoName} after ${timeout}ms`);
}
const waitForDisconnected = async (n8n: n8nPage, timeout = 30000) => {
await expect(async () => {
const response = await n8n.page.request.get('/rest/source-control/preferences');
const preferences = await response.json();
expect(preferences.data?.connected).toBe(false);
}).toPass({ timeout });
};
const initSourceControlPreferences = async (n8n: n8nPage) => {
await n8n.page.request.post('/rest/source-control/preferences', {
data: {
connectionType: 'ssh',
keyGeneratorType: 'ed25519',
repositoryUrl: '', // Clear any existing repo URL to prevent auto-reconnection
initRepo: false, // Don't initialize repo - this would set connected=true
},
});
};
const initSourceControlSSHKey = async ({ n8n, gitea }: { n8n: n8nPage; gitea: GiteaHelper }) => {
const preferencesResponse = await n8n.page.request.get('/rest/source-control/preferences');
const preferences = await preferencesResponse.json();
const sshKey = preferences.data.publicKey;
try {
await gitea.addSSHKey('n8n-source-control', sshKey);
} catch {
// Key might already exist in Gitea - this is fine if we're reusing keys
}
};
export const initSourceControl = async ({ n8n, gitea }: { n8n: n8nPage; gitea: GiteaHelper }) => {
const preferencesResponse = await n8n.page.request.get('/rest/source-control/preferences');
const preferences = await preferencesResponse.json();
if (preferences.data?.connected) {
await n8n.api.sourceControl.disconnect({ keepKeyPair: true });
await waitForDisconnected(n8n);
}
await initSourceControlPreferences(n8n);
await initSourceControlSSHKey({ n8n, gitea });
};
export function generateUniqueRepoName(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `n8n-test-${timestamp}-${random}`;
}
export function buildRepoUrl(repoName: string): string {
return `ssh://git@gitea/giteaadmin/${repoName}.git`;
}
export interface GitRepoHelper {
repoName: string;
repoUrl: string;
pushAndWait(
n8n: n8nPage,
commitMessage: string,
): Promise<{
files: SourceControlledFile[];
commit: GitCommitInfo | null;
}>;
}
export async function setupGitRepo(n8n: n8nPage, gitea: GiteaHelper): Promise<GitRepoHelper> {
await initSourceControl({ n8n, gitea });
const repoName = generateUniqueRepoName();
await gitea.createRepo(repoName);
const repoUrl = buildRepoUrl(repoName);
await n8n.api.sourceControl.connect({ repositoryUrl: repoUrl });
return {
repoName,
repoUrl,
async pushAndWait(n8nPage: n8nPage, commitMessage: string) {
const result = await n8nPage.sourceControlPushModal.push(commitMessage);
if (result.commit?.hash) {
await waitForCommitOnGitea(gitea, repoName, result.commit.hash);
}
return result;
},
};
}
@@ -0,0 +1,24 @@
/**
* Extract port from a URL string
*/
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;
}