import { defineConfig, devices } from '@playwright/test'; import dotenv from 'dotenv'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'path'; import { applyPlaywrightDatabaseEnv } from './src/__tests__/integration/utils/playwrightDatabaseConfig'; function loadPlaywrightEnv(): string | null { const candidates = ['.env', '.env.test', '.env.example'] .map((filename) => path.resolve(__dirname, filename)) .filter((candidate) => fs.existsSync(candidate)); const selected = candidates[0] ?? null; if (!selected) { console.warn(`[Playwright] couldn't find env file: ${path.resolve(__dirname, '.env')}`); return null; } dotenv.config({ path: selected }); return selected; } loadPlaywrightEnv(); const dockerComposeEnvFile = fs.existsSync(path.resolve(__dirname, '.env')) ? 'ee/server/.env' : 'ee/server/.env.test'; // Playwright runs should be self-contained and must not depend on developer filesystem secrets. // Playwright runs should be self-contained. Use an isolated filesystem-backed tenant secret store // (env provider is read-only and cannot persist per-tenant configuration like integrations). process.env.SECRET_READ_CHAIN = process.env.SECRET_READ_CHAIN || 'filesystem,env'; process.env.SECRET_WRITE_PROVIDER = process.env.SECRET_WRITE_PROVIDER || 'filesystem'; process.env.SECRET_FS_BASE_PATH = process.env.SECRET_FS_BASE_PATH || 'secrets-playwright'; process.env.PLAYWRIGHT_FAKE_GOOGLE_OAUTH = process.env.PLAYWRIGHT_FAKE_GOOGLE_OAUTH || 'true'; process.env.NEXT_PUBLIC_PLAYWRIGHT_FAKE_GOOGLE_OAUTH = process.env.NEXT_PUBLIC_PLAYWRIGHT_FAKE_GOOGLE_OAUTH || 'true'; process.env.GOOGLE_OAUTH_CLIENT_ID = process.env.GOOGLE_OAUTH_CLIENT_ID || 'playwright-google-client-id'; process.env.GOOGLE_OAUTH_CLIENT_SECRET = process.env.GOOGLE_OAUTH_CLIENT_SECRET || 'playwright-google-client-secret'; // Don't set STORAGE_LOCAL_BASE_PATH - we want to use MinIO for tests // const storageBasePath = path.resolve(__dirname, 'playwright-storage'); // if (!fs.existsSync(storageBasePath)) { // fs.mkdirSync(storageBasePath, { recursive: true }); // } // process.env.STORAGE_LOCAL_BASE_PATH = process.env.STORAGE_LOCAL_BASE_PATH || storageBasePath; // If Postgres runs in Docker and is published to a different host/port, // override the Playwright DB connection to hit the direct Postgres port instead // of PgBouncer. // // IMPORTANT: Do not fall back to DB_HOST/DB_PORT from `ee/server/.env` here. // That file is a developer runtime env and may use non-default ports; Playwright // should be self-contained and only deviate from defaults when explicitly configured. const directDbHost = process.env.DB_DIRECT_HOST || process.env.PLAYWRIGHT_DB_HOST || process.env.EXPOSE_DB_HOST || 'localhost'; const directDbPort = process.env.DB_DIRECT_PORT || process.env.PLAYWRIGHT_DB_PORT || process.env.EXPOSE_DB_PORT || '5432'; process.env.DB_DIRECT_HOST = process.env.DB_DIRECT_HOST || directDbHost; process.env.DB_DIRECT_PORT = process.env.DB_DIRECT_PORT || directDbPort; process.env.PLAYWRIGHT_DB_HOST = process.env.PLAYWRIGHT_DB_HOST || directDbHost; process.env.PLAYWRIGHT_DB_PORT = process.env.PLAYWRIGHT_DB_PORT || directDbPort; // Playwright runs should be deterministic and not inherit a developer's NODE_ENV, // since it affects auth cookie naming and other runtime branches. process.env.NODE_ENV = 'test'; function runPortProbe(start: number, span: number, strict: boolean): { success: boolean; port?: number; error?: string } { const script = `const net = require('net'); const start = Number(process.argv[1]); const attempts = Number(process.argv[2]); const strictMode = process.argv[3] === 'strict'; if (!Number.isFinite(start)) { console.error('invalid-port'); process.exit(2); } function tryPort(port, attemptsLeft) { const server = net.createServer(); server.unref(); server.once('error', (err) => { if (strictMode || attemptsLeft <= 0) { console.error(err && err.code ? err.code : 'EADDRINUSE'); process.exit(1); } tryPort(port + 1, attemptsLeft - 1); }); server.listen(port, () => { server.close(() => { process.stdout.write(String(port)); process.exit(0); }); }); } tryPort(start, attempts); `; const result = spawnSync(process.execPath, ['--input-type=commonjs', '-e', script, String(start), String(span), strict ? 'strict' : 'flex'], { encoding: 'utf-8', }); if (result.status === 0 && result.stdout) { return { success: true, port: Number(result.stdout.trim()) }; } const error = (result.stderr || result.error?.message || '').trim(); return { success: false, error: error || 'port-probe-failed' }; } function isPortProbePermissionError(error?: string): boolean { if (!error) return false; const normalized = error.trim().toUpperCase(); // Some sandboxed environments forbid binding/listening, causing EPERM/EACCES. return normalized.includes('EPERM') || normalized.includes('EACCES'); } function resolveWebPortSync(): number { const preferred = Number(process.env.PLAYWRIGHT_APP_PORT || process.env.APP_PORT || 3300); if (process.env.PLAYWRIGHT_APP_PORT_LOCKED === 'true' && process.env.PLAYWRIGHT_APP_PORT) { if (!Number.isFinite(preferred)) { throw new Error(`Invalid PLAYWRIGHT_APP_PORT value: ${process.env.PLAYWRIGHT_APP_PORT}`); } return preferred; } if (process.env.PLAYWRIGHT_APP_PORT) { if (!Number.isFinite(preferred)) { throw new Error(`Invalid PLAYWRIGHT_APP_PORT value: ${process.env.PLAYWRIGHT_APP_PORT}`); } const check = runPortProbe(preferred, 0, true); if (!check.success || typeof check.port !== 'number') { if (isPortProbePermissionError(check.error)) { console.warn(`[Playwright] Port probe blocked (${check.error}); using PLAYWRIGHT_APP_PORT=${preferred} without validation.`); return preferred; } // Even if a preferred port is provided, fall back to scanning for a nearby // available port to avoid spurious failures when developers have multiple // environments running locally. const fallback = runPortProbe(preferred, 25, false); if (!fallback.success || typeof fallback.port !== 'number') { throw new Error(`PLAYWRIGHT_APP_PORT=${preferred} is unavailable (${check.error || 'unknown error'}).`); } return fallback.port; } return check.port; } const probe = runPortProbe(preferred, 25, false); if (!probe.success || typeof probe.port !== 'number') { if (isPortProbePermissionError(probe.error)) { console.warn(`[Playwright] Port probe blocked (${probe.error}); defaulting to port ${preferred}.`); return preferred; } throw new Error(`Unable to find an available port for the Playwright dev server (${probe.error || 'probe failed'}).`); } return probe.port; } function resolveServicePortSync(envVarName: string, fallbackPort: number): number { const preferred = Number(process.env[envVarName] || fallbackPort); if (!Number.isFinite(preferred)) { throw new Error(`Invalid ${envVarName} value: ${process.env[envVarName]}`); } const lockedVarName = `${envVarName}_LOCKED`; if (process.env[lockedVarName] === 'true') { return preferred; } // If the preferred port is taken, scan forward for an available one (up to 25). const probe = runPortProbe(preferred, 25, false); if (!probe.success || typeof probe.port !== 'number') { if (isPortProbePermissionError(probe.error)) { console.warn(`[Playwright] Port probe blocked (${probe.error}); using ${envVarName}=${preferred} without validation.`); return preferred; } throw new Error(`Unable to find an available port for ${envVarName} starting at ${preferred} (${probe.error || 'probe failed'}).`); } return probe.port; } const PORT_CACHE_KEY = Symbol.for('__ALGA_PLAYWRIGHT_PORT__'); function getCachedWebPort(): number { const cached = globalThis[PORT_CACHE_KEY]; if (typeof cached === 'number' && Number.isFinite(cached)) { return cached; } console.log('[Playwright] env before port detection', { PLAYWRIGHT_APP_PORT: process.env.PLAYWRIGHT_APP_PORT, APP_PORT: process.env.APP_PORT, PORT: process.env.PORT, }); const resolved = resolveWebPortSync(); globalThis[PORT_CACHE_KEY] = resolved; console.log(`[Playwright] using dev server port ${resolved}`); return resolved; } const resolvedWebPort = getCachedWebPort(); const webHost = process.env.PLAYWRIGHT_APP_HOST || 'localhost'; const resolvedBaseUrl = process.env.PLAYWRIGHT_BASE_URL || `http://${webHost}:${resolvedWebPort}`; const resolvedHostForEnv = (() => { try { return new URL(resolvedBaseUrl).host; } catch { return `${webHost}:${resolvedWebPort}`; } })(); process.env.PLAYWRIGHT_APP_PORT = String(resolvedWebPort); process.env.PLAYWRIGHT_APP_PORT_LOCKED = 'true'; process.env.HOST = process.env.HOST || resolvedHostForEnv; process.env.APP_PORT = process.env.APP_PORT || String(resolvedWebPort); process.env.EXPOSE_SERVER_PORT = process.env.EXPOSE_SERVER_PORT || String(resolvedWebPort); process.env.PORT = process.env.PORT || String(resolvedWebPort); // Reserve ports for dockerized Playwright deps (postgres/redis/worker). const resolvedPlaywrightDbPort = resolveServicePortSync('PLAYWRIGHT_DB_PORT', Number(directDbPort || 5439)); process.env.PLAYWRIGHT_DB_PORT = String(resolvedPlaywrightDbPort); process.env.PLAYWRIGHT_DB_PORT_LOCKED = 'true'; process.env.DB_DIRECT_PORT = String(resolvedPlaywrightDbPort); process.env.PLAYWRIGHT_DB_PORT = String(resolvedPlaywrightDbPort); const resolvedPlaywrightRedisPort = resolveServicePortSync('REDIS_PORT', Number(process.env.REDIS_PORT || 16379)); process.env.REDIS_PORT = String(resolvedPlaywrightRedisPort); process.env.REDIS_PORT_LOCKED = 'true'; const resolvedPlaywrightHocuspocusPort = resolveServicePortSync( 'PLAYWRIGHT_HOCUSPOCUS_PORT', Number(process.env.PLAYWRIGHT_HOCUSPOCUS_PORT || process.env.EXPOSE_HOCUSPOCUS_PORT || 1234) ); process.env.PLAYWRIGHT_HOCUSPOCUS_PORT = String(resolvedPlaywrightHocuspocusPort); process.env.PLAYWRIGHT_HOCUSPOCUS_PORT_LOCKED = 'true'; process.env.EXPOSE_HOCUSPOCUS_PORT = String(resolvedPlaywrightHocuspocusPort); process.env.NEXT_PUBLIC_HOCUSPOCUS_URL = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL || `ws://localhost:${resolvedPlaywrightHocuspocusPort}`; process.env.HOCUSPOCUS_JWT_SECRET = process.env.HOCUSPOCUS_JWT_SECRET || 'dev-hocuspocus-jwt-secret'; process.env.REDIS_PASSWORD = process.env.REDIS_PASSWORD || 'sebastian123'; const skipWorkflowWorker = parseTruthyEnv(process.env.PLAYWRIGHT_SKIP_WORKFLOW_WORKER); // Apply Playwright-specific database configuration (after port resolution). applyPlaywrightDatabaseEnv(); function parseTruthyEnv(value?: string): boolean { if (!value) return false; const normalized = value.trim().toLowerCase(); return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'y' || normalized === 'on'; } // Decide whether Playwright should manage starting the dev server. // - In CI, some pipelines start the server externally and only want tests to connect. // - However, if no explicit base URL is provided, disabling webServer causes hard-to-debug ECONNREFUSED. const explicitBaseUrlProvided = Boolean( process.env.PLAYWRIGHT_BASE_URL || process.env.EE_BASE_URL || process.env.NEXTAUTH_URL ); const isCi = parseTruthyEnv(process.env.CI); const shouldRunWebServer = process.env.PW_WEBSERVER === 'true' ? true : process.env.PW_WEBSERVER === 'false' ? false : !(isCi && explicitBaseUrlProvided); // If Playwright is managing the dev server, make the computed base URL authoritative. // This avoids a mismatch where an env file sets EE_BASE_URL/NEXTAUTH_URL to a different port. if (shouldRunWebServer && !process.env.PLAYWRIGHT_BASE_URL) { process.env.EE_BASE_URL = resolvedBaseUrl; process.env.NEXTAUTH_URL = resolvedBaseUrl; } else { process.env.EE_BASE_URL = process.env.EE_BASE_URL || resolvedBaseUrl; process.env.NEXTAUTH_URL = process.env.NEXTAUTH_URL || resolvedBaseUrl; } console.log('[Playwright] webServer', { CI: process.env.CI, isCi, explicitBaseUrlProvided, shouldRunWebServer, resolvedBaseUrl, EE_BASE_URL: process.env.EE_BASE_URL, NEXTAUTH_URL: process.env.NEXTAUTH_URL, }); /** * Playwright configuration for EE server integration tests * @see https://playwright.dev/docs/test-configuration */ export default defineConfig({ testDir: './src/__tests__/integration', // Run all Playwright integration tests in this folder testMatch: ['**/*.playwright.test.ts'], /* Global setup file */ globalSetup: './playwright.global-setup.ts', /* Global teardown file */ globalTeardown: './playwright.global-teardown.ts', /* Run tests in files in parallel */ fullyParallel: false, // Disabled for database isolation /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry control: CI default 2, local default 0, override with PW_RETRIES */ retries: process.env.PW_RETRIES !== undefined ? Number(process.env.PW_RETRIES) : (process.env.CI ? 2 : 0), /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : 1, // Single worker for database isolation /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: [ ['list'], ['json', { outputFile: 'playwright-report/results.json' }], ['html', { open: 'never' }] ], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: resolvedBaseUrl, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', /* Take screenshot on failure */ screenshot: 'only-on-failure', /* Record video on failure */ video: 'retain-on-failure', /* Global test timeout */ actionTimeout: 15000, navigationTimeout: 30000, }, /* Configure projects for major browsers */ projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: { headless: false, args: [ '--host-resolver-rules=MAP portal.acme.local 127.0.0.1,MAP canonical.localhost 127.0.0.1,MAP localhost 127.0.0.1' ], }, }, }, // Uncomment for cross-browser testing // { // name: 'firefox', // use: { ...devices['Desktop Firefox'] }, // }, // { // name: 'webkit', // use: { ...devices['Desktop Safari'] }, // }, /* Test against mobile viewports. */ // { // name: 'Mobile Chrome', // use: { ...devices['Pixel 5'] }, // }, // { // name: 'Mobile Safari', // use: { ...devices['iPhone 12'] }, // }, ], /* Run your local dev server before starting the tests */ webServer: shouldRunWebServer ? { // Reset DB once per session before starting the dev server. command: 'cd ../../' + ` && PLAYWRIGHT_DB_PORT=${resolvedPlaywrightDbPort} REDIS_PORT=${resolvedPlaywrightRedisPort} PLAYWRIGHT_HOCUSPOCUS_PORT=${resolvedPlaywrightHocuspocusPort} REDIS_PASSWORD=${process.env.REDIS_PASSWORD} HOCUSPOCUS_JWT_SECRET=${process.env.HOCUSPOCUS_JWT_SECRET} docker compose -f docker-compose.playwright-workflow-deps.yml -p alga-psa-playwright-workflow --env-file ${dockerComposeEnvFile} up -d --wait --wait-timeout 60 postgres-playwright redis-playwright` + ' && node --import tsx/esm scripts/bootstrap-playwright-db.ts' + (skipWorkflowWorker ? ` && PLAYWRIGHT_DB_PORT=${resolvedPlaywrightDbPort} REDIS_PORT=${resolvedPlaywrightRedisPort} PLAYWRIGHT_HOCUSPOCUS_PORT=${resolvedPlaywrightHocuspocusPort} REDIS_PASSWORD=${process.env.REDIS_PASSWORD} HOCUSPOCUS_JWT_SECRET=${process.env.HOCUSPOCUS_JWT_SECRET} docker compose -f docker-compose.playwright-workflow-deps.yml -p alga-psa-playwright-workflow --env-file ${dockerComposeEnvFile} up -d --build --wait --wait-timeout 120 hocuspocus-playwright` : ` && PLAYWRIGHT_DB_PORT=${resolvedPlaywrightDbPort} REDIS_PORT=${resolvedPlaywrightRedisPort} PLAYWRIGHT_HOCUSPOCUS_PORT=${resolvedPlaywrightHocuspocusPort} REDIS_PASSWORD=${process.env.REDIS_PASSWORD} HOCUSPOCUS_JWT_SECRET=${process.env.HOCUSPOCUS_JWT_SECRET} docker compose -f docker-compose.playwright-workflow-deps.yml -p alga-psa-playwright-workflow --env-file ${dockerComposeEnvFile} up -d --build --wait --wait-timeout 120 workflow-worker-playwright hocuspocus-playwright`) + ' && NEXT_PUBLIC_EDITION=enterprise npm run dev', url: resolvedBaseUrl, // Default to starting a fresh server (we also need to bring up dockerized deps + reset the DB). // Opt-in reuse with PW_REUSE=true for local iteration. reuseExistingServer: process.env.PW_REUSE === 'true', timeout: 300000, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, // Use a writable filesystem secret store for tenant secrets; keep app secrets in env. SECRET_READ_CHAIN: process.env.SECRET_READ_CHAIN || 'filesystem,env', SECRET_WRITE_PROVIDER: process.env.SECRET_WRITE_PROVIDER || 'filesystem', SECRET_FS_BASE_PATH: process.env.SECRET_FS_BASE_PATH || 'secrets-playwright', NEXT_PUBLIC_EDITION: 'enterprise', E2E_AUTH_BYPASS: 'true', EE_BASE_URL: resolvedBaseUrl, NEXTAUTH_URL: resolvedBaseUrl, HOST: resolvedHostForEnv, HOSTNAME: webHost, PORT: String(resolvedWebPort), APP_PORT: String(resolvedWebPort), EXPOSE_SERVER_PORT: String(resolvedWebPort), NEXT_PUBLIC_APP_URL: resolvedBaseUrl, NEXT_PUBLIC_SITE_URL: resolvedBaseUrl, NEXT_PUBLIC_API_BASE_URL: resolvedBaseUrl, NEXT_PUBLIC_EXTERNAL_APP_URL: resolvedBaseUrl, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET || 'test-nextauth-secret', NEXT_PUBLIC_DISABLE_FEATURE_FLAGS: process.env.NEXT_PUBLIC_DISABLE_FEATURE_FLAGS ?? 'true', NEXT_PUBLIC_HOCUSPOCUS_URL: process.env.NEXT_PUBLIC_HOCUSPOCUS_URL || `ws://localhost:${resolvedPlaywrightHocuspocusPort}`, HOCUSPOCUS_JWT_SECRET: process.env.HOCUSPOCUS_JWT_SECRET || 'dev-hocuspocus-jwt-secret', REDIS_PASSWORD: process.env.REDIS_PASSWORD || 'sebastian123', // Explicitly set DB config for Playwright - override server/.env settings DB_HOST: process.env.PLAYWRIGHT_DB_HOST || process.env.DB_HOST || 'localhost', DB_PORT: process.env.PLAYWRIGHT_DB_PORT || process.env.DB_PORT || '5439', DB_NAME: process.env.PLAYWRIGHT_DB_NAME || process.env.DB_NAME || 'alga_contract_wizard_test', DB_NAME_SERVER: process.env.PLAYWRIGHT_DB_NAME || process.env.DB_NAME_SERVER || 'alga_contract_wizard_test', DB_USER: process.env.PLAYWRIGHT_DB_ADMIN_USER || process.env.DB_USER || 'postgres', DB_PASSWORD: process.env.PLAYWRIGHT_DB_ADMIN_PASSWORD || process.env.DB_PASSWORD || '', DB_USER_SERVER: process.env.PLAYWRIGHT_DB_ADMIN_USER || process.env.DB_USER_SERVER || 'postgres', DB_PASSWORD_SERVER: process.env.PLAYWRIGHT_DB_ADMIN_PASSWORD || process.env.DB_PASSWORD_SERVER || '', DB_PASSWORD_ADMIN: process.env.PLAYWRIGHT_DB_ADMIN_PASSWORD || process.env.DB_PASSWORD_ADMIN || '', // Use S3/MinIO for file uploads (not local storage) // MinIO test instance runs on port 9002 (separate from Payload MinIO on 9000) STORAGE_DEFAULT_PROVIDER: 's3', // Use S3/MinIO instead of local storage STORAGE_S3_ENDPOINT: process.env.STORAGE_S3_ENDPOINT || 'http://localhost:9002', STORAGE_S3_ACCESS_KEY: process.env.STORAGE_S3_ACCESS_KEY || 'minioadmin', STORAGE_S3_SECRET_KEY: process.env.STORAGE_S3_SECRET_KEY || 'minioadmin', STORAGE_S3_BUCKET: process.env.STORAGE_S3_BUCKET || 'alga-test', STORAGE_S3_REGION: process.env.STORAGE_S3_REGION || 'us-east-1', STORAGE_S3_FORCE_PATH_STYLE: process.env.STORAGE_S3_FORCE_PATH_STYLE || 'true', // Redis is required for the event bus; prefer a local instance for Playwright. REDIS_HOST: process.env.REDIS_HOST || 'localhost', REDIS_PORT: process.env.REDIS_PORT || '6379', } } : undefined, /* Global test timeout */ timeout: 60000, /* Test output directory */ outputDir: 'playwright-test-results/', });