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; /** 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; }; 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 = (() => { const raw = process.env.N8N_TEST_ENV; if (!raw) return {}; try { return JSON.parse(raw) as Record; } 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 }> = []; const createApi = async (mainIndex: number): Promise => { 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 => { 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.) */