mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
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 {
|
|
quarantineFixtures,
|
|
type QuarantineTestFixtures,
|
|
type QuarantineWorkerFixtures,
|
|
} from '../fixtures/quarantine';
|
|
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 & QuarantineTestFixtures,
|
|
WorkerFixtures & CurrentsWorkerFixtures & QuarantineWorkerFixtures
|
|
>({
|
|
...currentsFixtures.baseFixtures,
|
|
...currentsFixtures.coverageFixtures,
|
|
...currentsFixtures.actionFixtures,
|
|
...observabilityFixtures,
|
|
...consoleErrorFixtures,
|
|
...quarantineFixtures,
|
|
|
|
// 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 globalEnv: Record<string, string> = (() => {
|
|
const raw = process.env.N8N_TEST_ENV;
|
|
if (!raw) return {};
|
|
try {
|
|
return JSON.parse(raw) as Record<string, string>;
|
|
} catch {
|
|
console.warn('[base.ts] Failed to parse N8N_TEST_ENV');
|
|
return {};
|
|
}
|
|
})();
|
|
|
|
const config: N8NConfig = {
|
|
...base,
|
|
...override,
|
|
services: [...new Set([...(base.services ?? []), ...(override.services ?? [])])],
|
|
env: {
|
|
...globalEnv,
|
|
...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.)
|
|
*/
|