import { defineConfig, devices, PlaywrightTestConfig } from '@playwright/test' import path from 'path' import { fileURLToPath } from 'url' // ES module equivalent of __dirname const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) /** * Shared Base Playwright Configuration * * This configuration provides common defaults for all apps in the monorepo. * Each app extends this config and overrides specific settings as needed. * * Test outputs are centralized to /test-output/{app-name}/ in monorepo root. * This keeps test results organized and prevents git tracking issues. * * @see https://playwright.dev/docs/test-configuration */ export type DevicePreset = | 'desktop' | 'mobile' | 'tablet' | 'obs-overlay' | 'chromium-only' | 'all' export interface ClusterModeOptions { /** Enable cluster mode (defaults to E2E_USE_CLUSTER env var) */ enabled?: boolean /** Deployment name for nginx routing (e.g., 'fanclub', 'private/channel') */ deploymentName?: string /** App name for cluster manager (e.g., 'fan-club', 'channel-studio') */ appName?: string /** Cluster manager script to use (default: 'e2e-cluster-start.js') */ clusterScript?: 'e2e-cluster-start.js' | 'e2e-wrapper.js' /** Cluster startup timeout in ms (default: 180000) */ clusterTimeout?: number } export interface SharedPlaywrightConfigOptions { /** Base URL for the app (e.g., 'http://localhost:3000') */ baseURL?: string /** Port for the dev server (e.g., 3000) */ port?: number /** Test directory relative to app root (default: './e2e') */ testDir?: string /** Dev server start command (default: 'pnpm dev') */ devCommand?: string /** Maximum test timeout in milliseconds (default: 30000) */ timeout?: number /** Whether to run tests in parallel (default: true) */ fullyParallel?: boolean /** Number of workers (default: CI ? 1 : undefined) */ workers?: number /** Number of retries on failure (default: CI ? 2 : 0) */ retries?: number /** Whether to reuse existing dev server (default: !CI) */ reuseServer?: boolean /** App name for centralized output directory (e.g., 'fan-club') */ appName?: string /** Device preset to use (default: 'desktop') */ devicePreset?: DevicePreset /** Cluster mode configuration */ clusterMode?: ClusterModeOptions /** Auth setup project configuration */ authSetup?: { /** Enable auth setup project (default: false) */ enabled: boolean /** Auth storage state path (default: 'e2e/.auth/performer.json') */ storagePath?: string } /** Environment variables for dev server (e.g., { VITE_ENABLE_MSW: 'true' }) */ env?: Record } /** * Creates a Playwright config with shared defaults * Apps can extend this and override specific settings */ export function createPlaywrightConfig( options: SharedPlaywrightConfigOptions = {} ): PlaywrightTestConfig { const { baseURL: userBaseURL, port: userPort, testDir = './e2e', devCommand = 'pnpm dev', timeout = 30000, fullyParallel = true, workers = process.env.CI ? 1 : undefined, retries = process.env.CI ? 2 : 0, reuseServer = !process.env.CI, appName, devicePreset = 'desktop', clusterMode, authSetup, env: envVars, } = options // Determine if cluster mode is enabled const useCluster = clusterMode?.enabled ?? process.env.E2E_USE_CLUSTER === 'true' // Determine deployment name and base URL const deploymentName = clusterMode?.deploymentName || process.env.E2E_DEPLOYMENT || 'app' const clusterAppName = clusterMode?.appName || appName || 'app' // Cluster mode uses nginx routing, standalone uses direct port const defaultPort = useCluster ? 80 : 3000 const port = userPort ?? defaultPort const defaultBaseURL = useCluster ? `http://localhost/${deploymentName}` : `http://localhost:${port}` const baseURL = userBaseURL ?? defaultBaseURL // Determine monorepo root (assumes this package is in @packages/config) const monorepoRoot = path.resolve(__dirname, '../..') // Centralized test output directory const outputDir = appName ? path.join(monorepoRoot, 'test-output', appName) : 'test-results' // Fallback for apps not using appName // Select device projects based on preset const projects = getDeviceProjects(devicePreset, authSetup) // Build webServer configuration const clusterScript = clusterMode?.clusterScript || 'e2e-cluster-start.js' const clusterTimeout = clusterMode?.clusterTimeout || 180000 // Build environment variables string for command prefix const envPrefix = envVars ? Object.entries(envVars) .map(([key, value]) => `${key}=${value}`) .join(' ') + ' ' : '' const webServer = useCluster ? { // Cluster mode: Use cluster manager script command: `node ../../scripts/cluster-manager/${clusterScript}`, url: baseURL, port: clusterScript === 'e2e-wrapper.js' ? port : undefined, reuseExistingServer: reuseServer, timeout: clusterTimeout, env: { E2E_APP: clusterAppName, E2E_DEPLOYMENT: deploymentName, ...(envVars || {}), }, } : { // Standalone mode: Prefix command with env vars for Vite to pick them up command: `${envPrefix}${devCommand}`, port, reuseExistingServer: reuseServer, timeout: 120000, stdout: 'ignore', stderr: 'pipe', } return defineConfig({ testDir, timeout, fullyParallel, forbidOnly: !!process.env.CI, retries, workers, // Shared reporter configuration reporter: [ ['list'], ['html', { outputFolder: path.join(outputDir, 'html-report'), open: 'never' }], ['junit', { outputFile: path.join(outputDir, 'junit.xml') }], ...(process.env.CI ? [['github' as const]] : []), ], // Shared expect configuration expect: { timeout: 5000, }, // Shared test configuration use: { baseURL, headless: true, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', actionTimeout: 10000, navigationTimeout: 15000, }, // Device-specific projects projects, // Dev server configuration webServer, // Centralized output directory outputDir: path.join(outputDir, 'test-results'), }) } /** * Get device projects based on preset */ function getDeviceProjects( preset: DevicePreset, authSetup?: SharedPlaywrightConfigOptions['authSetup'] ) { const projects: any[] = [] // Add auth setup project if enabled if (authSetup?.enabled) { const storagePath = authSetup.storagePath || 'e2e/.auth/performer.json' projects.push({ name: 'setup', testMatch: /.*\.setup\.ts/, use: { storageState: undefined, // Don't load auth for setup }, }) // Helper to add device with auth const addAuthDevice = (name: string, device: any) => { projects.push({ name, use: { ...device, storageState: storagePath, serviceWorkers: 'block', // Prevent service worker caching issues }, dependencies: ['setup'], }) } // Build device projects with auth switch (preset) { case 'chromium-only': addAuthDevice('chromium', devices['Desktop Chrome']) break case 'desktop': addAuthDevice('chromium', devices['Desktop Chrome']) addAuthDevice('firefox', devices['Desktop Firefox']) break case 'mobile': addAuthDevice('mobile-chrome', devices['Pixel 5']) addAuthDevice('mobile-safari', devices['iPhone 13']) break case 'tablet': addAuthDevice('ipad-pro', devices['iPad Pro']) break case 'obs-overlay': addAuthDevice('chromium', devices['Desktop Chrome']) addAuthDevice('obs-overlay', { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 }, hasTouch: false, reducedMotion: 'no-preference', }) break case 'all': addAuthDevice('chromium', devices['Desktop Chrome']) addAuthDevice('firefox', devices['Desktop Firefox']) addAuthDevice('webkit', devices['Desktop Safari']) addAuthDevice('mobile-chrome', devices['Pixel 5']) addAuthDevice('mobile-safari', devices['iPhone 13']) break } } else { // No auth setup - return standard device projects switch (preset) { case 'chromium-only': projects.push({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }) break case 'desktop': projects.push( { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, } ) break case 'mobile': projects.push( { name: 'mobile-chrome', use: { ...devices['Pixel 5'] }, }, { name: 'mobile-safari', use: { ...devices['iPhone 13'] }, } ) break case 'tablet': projects.push({ name: 'ipad-pro', use: { ...devices['iPad Pro'] }, }) break case 'obs-overlay': projects.push( { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'obs-overlay', use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 }, hasTouch: false, reducedMotion: 'no-preference', }, } ) break case 'all': projects.push( { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, { name: 'mobile-chrome', use: { ...devices['Pixel 5'] }, }, { name: 'mobile-safari', use: { ...devices['iPhone 13'] }, } ) break } } return projects } /** * Default export for apps that just want standard config */ export default createPlaywrightConfig() /** * Common device configurations apps can use */ export const commonDevices = { // Desktop desktopChrome: { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, desktopFirefox: { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, desktopSafari: { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, // Mobile mobileChrome: { name: 'mobile-chrome', use: { ...devices['Pixel 5'] }, }, mobileSafari: { name: 'mobile-safari', use: { ...devices['iPhone 13'] }, }, // Tablet ipadPro: { name: 'ipad-pro', use: { ...devices['iPad Pro'] }, }, // OBS Overlay (1920x1080 for OBS Browser Source) obsOverlay: { name: 'obs-overlay', use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 }, hasTouch: false, reducedMotion: 'no-preference', }, }, }