param( [string]$AdminUsername = 'e2e_admin', [string]$AdminPassword = 'E2EAdmin@123456', [string]$AdminEmail = 'e2e_admin@example.com', [int]$BrowserPort = 0, [int]$BackendPort = 0, [int]$FrontendPort = 0 ) $ErrorActionPreference = 'Stop' function Resolve-E2ERoots { $scriptFrontendRoot = Resolve-Path (Join-Path $PSScriptRoot '..') -ErrorAction SilentlyContinue $scriptProjectRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..') -ErrorAction SilentlyContinue $cwdFrontendRoot = Resolve-Path (Get-Location).Path $cwdProjectRoot = Resolve-Path (Join-Path $cwdFrontendRoot '..\..') -ErrorAction SilentlyContinue if ( $scriptFrontendRoot -and $scriptProjectRoot -and (Test-Path (Join-Path $scriptFrontendRoot 'package.json')) -and (Test-Path (Join-Path $scriptProjectRoot 'go.mod')) ) { return [pscustomobject]@{ FrontendRoot = $scriptFrontendRoot.Path ProjectRoot = $scriptProjectRoot.Path } } if ( $cwdProjectRoot -and (Test-Path (Join-Path $cwdFrontendRoot 'package.json')) -and (Test-Path (Join-Path $cwdProjectRoot 'go.mod')) ) { return [pscustomobject]@{ FrontendRoot = $cwdFrontendRoot ProjectRoot = $cwdProjectRoot.Path } } throw 'failed to resolve frontend/project roots for playwright e2e' } $resolvedRoots = Resolve-E2ERoots $projectRoot = $resolvedRoots.ProjectRoot $frontendRoot = $resolvedRoots.FrontendRoot $serverExePath = Join-Path $env:TEMP ("ums-server-playwright-e2e-" + [guid]::NewGuid().ToString('N') + '.exe') $e2eRunRoot = Join-Path $env:TEMP ("ums-playwright-e2e-" + [guid]::NewGuid().ToString('N')) $goCacheDir = Join-Path $e2eRunRoot 'go-build' $goModCacheDir = Join-Path $e2eRunRoot 'gomod' $goPathDir = Join-Path $e2eRunRoot 'gopath' $e2eDataRoot = Join-Path $e2eRunRoot 'data' $e2eDbPath = Join-Path $e2eDataRoot 'user_management.e2e.db' $smtpCaptureFile = Join-Path $e2eRunRoot 'smtp-capture.jsonl' $e2eConfigPath = Join-Path $e2eRunRoot 'config.yaml' $bootstrapSecret = 'e2e-bootstrap-secret' New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eRunRoot, $e2eDataRoot | Out-Null Set-Content -Path $e2eConfigPath -Encoding utf8 -Value @( 'default:', ' admin_email: ""', ' admin_password: ""' ) function Get-FreeTcpPort { $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) $listener.Start() try { return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() } } function Test-UrlReady { param( [Parameter(Mandatory = $true)][string]$Url ) try { $response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2 return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500 } catch { return $false } } function Wait-UrlReady { param( [Parameter(Mandatory = $true)][string]$Url, [Parameter(Mandatory = $true)][string]$Label, [int]$RetryCount = 120, [int]$DelayMs = 500 ) for ($i = 0; $i -lt $RetryCount; $i++) { if (Test-UrlReady -Url $Url) { return } Start-Sleep -Milliseconds $DelayMs } throw "$Label did not become ready: $Url" } function Start-ManagedProcess { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$FilePath, [string[]]$ArgumentList = @(), [Parameter(Mandatory = $true)][string]$WorkingDirectory ) $stdoutPath = Join-Path $env:TEMP "$Name-stdout.log" $stderrPath = Join-Path $env:TEMP "$Name-stderr.log" Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue if ($ArgumentList -and $ArgumentList.Count -gt 0) { $process = Start-Process ` -FilePath $FilePath ` -ArgumentList $ArgumentList ` -WorkingDirectory $WorkingDirectory ` -PassThru ` -WindowStyle Hidden ` -RedirectStandardOutput $stdoutPath ` -RedirectStandardError $stderrPath } else { $process = Start-Process ` -FilePath $FilePath ` -WorkingDirectory $WorkingDirectory ` -PassThru ` -WindowStyle Hidden ` -RedirectStandardOutput $stdoutPath ` -RedirectStandardError $stderrPath } return [pscustomobject]@{ Name = $Name Process = $process StdOut = $stdoutPath StdErr = $stderrPath } } function Stop-ManagedProcess { param( [Parameter(Mandatory = $false)]$Handle ) if (-not $Handle) { return } if ($Handle.Process -and -not $Handle.Process.HasExited) { try { taskkill /PID $Handle.Process.Id /T /F *> $null } catch { Stop-Process -Id $Handle.Process.Id -Force -ErrorAction SilentlyContinue } } } function Show-ManagedProcessLogs { param( [Parameter(Mandatory = $false)]$Handle ) if (-not $Handle) { return } if (Test-Path $Handle.StdOut) { Get-Content $Handle.StdOut -ErrorAction SilentlyContinue } if (Test-Path $Handle.StdErr) { Get-Content $Handle.StdErr -ErrorAction SilentlyContinue } } function Remove-ManagedProcessLogs { param( [Parameter(Mandatory = $false)]$Handle ) if (-not $Handle) { return } Remove-Item $Handle.StdOut, $Handle.StdErr -Force -ErrorAction SilentlyContinue } $backendHandle = $null $frontendHandle = $null $smtpHandle = $null $selectedBackendPort = if ($BackendPort -gt 0) { $BackendPort } else { Get-FreeTcpPort } $selectedFrontendPort = if ($FrontendPort -gt 0) { $FrontendPort } else { Get-FreeTcpPort } $selectedSMTPPort = Get-FreeTcpPort $backendBaseUrl = "http://127.0.0.1:$selectedBackendPort" $frontendBaseUrl = "http://127.0.0.1:$selectedFrontendPort" try { Push-Location $projectRoot try { $env:GOCACHE = $goCacheDir $env:GOMODCACHE = $goModCacheDir $env:GOPATH = $goPathDir $env:GOTELEMETRY = 'off' go build -o $serverExePath ./cmd/server if ($LASTEXITCODE -ne 0) { throw 'server build failed' } } finally { Pop-Location Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue Remove-Item Env:GOPATH -ErrorAction SilentlyContinue Remove-Item Env:GOTELEMETRY -ErrorAction SilentlyContinue } $env:DATA_DIR = $e2eRunRoot $env:SERVER_PORT = "$selectedBackendPort" $env:DATABASE_DBNAME = $e2eDbPath $env:SERVER_MODE = 'debug' $env:SERVER_FRONTEND_URL = $frontendBaseUrl $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort" $env:LOGGING_OUTPUT = 'stdout' $env:EMAIL_HOST = '127.0.0.1' $env:EMAIL_PORT = "$selectedSMTPPort" $env:EMAIL_FROM_EMAIL = 'noreply@test.local' $env:EMAIL_FROM_NAME = 'UMS E2E' $env:BOOTSTRAP_SECRET = $bootstrapSecret # JWT secret must be at least 32 bytes $env:JWT_SECRET = 'e2e-test-jwt-secret-at-least-32-bytes-long-for-security' Write-Host "playwright e2e backend: $backendBaseUrl" Write-Host "playwright e2e frontend: $frontendBaseUrl" Write-Host "playwright e2e smtp: 127.0.0.1:$selectedSMTPPort" Write-Host "playwright e2e sqlite: $e2eDbPath" $smtpHandle = Start-ManagedProcess ` -Name 'ums-smtp-capture' ` -FilePath 'node' ` -ArgumentList @((Join-Path $PSScriptRoot 'mock-smtp-capture.mjs'), '--port', "$selectedSMTPPort", '--output', $smtpCaptureFile) ` -WorkingDirectory $frontendRoot Start-Sleep -Milliseconds 500 if ($smtpHandle.Process -and $smtpHandle.Process.HasExited) { Show-ManagedProcessLogs $smtpHandle throw 'smtp capture server failed to start' } $backendHandle = Start-ManagedProcess ` -Name 'ums-backend-playwright' ` -FilePath $serverExePath ` -WorkingDirectory $projectRoot try { Wait-UrlReady -Url "$backendBaseUrl/health" -Label 'backend' } catch { Show-ManagedProcessLogs $backendHandle throw } $env:VITE_API_PROXY_TARGET = $backendBaseUrl $env:VITE_API_BASE_URL = '/api/v1' $frontendHandle = Start-ManagedProcess ` -Name 'ums-frontend-playwright' ` -FilePath 'npm.cmd' ` -ArgumentList @('run', 'dev', '--', '--host', '127.0.0.1', '--port', "$selectedFrontendPort") ` -WorkingDirectory $frontendRoot try { Wait-UrlReady -Url $frontendBaseUrl -Label 'frontend' } catch { Show-ManagedProcessLogs $frontendHandle throw } $env:E2E_LOGIN_USERNAME = $AdminUsername $env:E2E_LOGIN_PASSWORD = $AdminPassword $env:E2E_LOGIN_EMAIL = $AdminEmail $env:E2E_BOOTSTRAP_SECRET = $bootstrapSecret $env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1' $env:E2E_EXTERNAL_WEB_SERVER = '1' $env:E2E_BASE_URL = $frontendBaseUrl $env:E2E_API_BASE_URL = "$backendBaseUrl/api/v1" $env:E2E_SMTP_CAPTURE_FILE = $smtpCaptureFile Push-Location $frontendRoot try { $lastError = $null $suiteAttempts = 2 if ($env:E2E_SUITE_ATTEMPTS) { $parsedSuiteAttempts = 0 if ([int]::TryParse($env:E2E_SUITE_ATTEMPTS, [ref]$parsedSuiteAttempts) -and $parsedSuiteAttempts -gt 0) { $suiteAttempts = $parsedSuiteAttempts } } for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) { try { & (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') ` -Port $BrowserPort ` -Command @('node', './scripts/run-playwright-cdp-e2e.mjs') $lastError = $null break } catch { $lastError = $_ if ($attempt -ge $suiteAttempts) { throw } $retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String } Write-Host "playwright-cdp suite retry: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason" Start-Sleep -Seconds 1 } } if ($lastError) { throw $lastError } } finally { Pop-Location Remove-Item Env:DATA_DIR -ErrorAction SilentlyContinue Remove-Item Env:E2E_LOGIN_USERNAME -ErrorAction SilentlyContinue Remove-Item Env:E2E_LOGIN_PASSWORD -ErrorAction SilentlyContinue Remove-Item Env:E2E_LOGIN_EMAIL -ErrorAction SilentlyContinue Remove-Item Env:E2E_BOOTSTRAP_SECRET -ErrorAction SilentlyContinue Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue Remove-Item Env:E2E_EXTERNAL_WEB_SERVER -ErrorAction SilentlyContinue Remove-Item Env:E2E_BASE_URL -ErrorAction SilentlyContinue Remove-Item Env:E2E_API_BASE_URL -ErrorAction SilentlyContinue Remove-Item Env:E2E_SMTP_CAPTURE_FILE -ErrorAction SilentlyContinue } } finally { Stop-ManagedProcess $frontendHandle Remove-ManagedProcessLogs $frontendHandle Stop-ManagedProcess $backendHandle Remove-ManagedProcessLogs $backendHandle Stop-ManagedProcess $smtpHandle Remove-ManagedProcessLogs $smtpHandle Remove-Item Env:SERVER_PORT -ErrorAction SilentlyContinue Remove-Item Env:DATABASE_DBNAME -ErrorAction SilentlyContinue Remove-Item Env:SERVER_MODE -ErrorAction SilentlyContinue Remove-Item Env:SERVER_FRONTEND_URL -ErrorAction SilentlyContinue Remove-Item Env:CORS_ALLOWED_ORIGINS -ErrorAction SilentlyContinue Remove-Item Env:LOGGING_OUTPUT -ErrorAction SilentlyContinue Remove-Item Env:EMAIL_HOST -ErrorAction SilentlyContinue Remove-Item Env:EMAIL_PORT -ErrorAction SilentlyContinue Remove-Item Env:EMAIL_FROM_EMAIL -ErrorAction SilentlyContinue Remove-Item Env:EMAIL_FROM_NAME -ErrorAction SilentlyContinue Remove-Item Env:VITE_API_PROXY_TARGET -ErrorAction SilentlyContinue Remove-Item Env:VITE_API_BASE_URL -ErrorAction SilentlyContinue Remove-Item Env:BOOTSTRAP_SECRET -ErrorAction SilentlyContinue Remove-Item Env:JWT_SECRET -ErrorAction SilentlyContinue Remove-Item Env:DEFAULT_ADMIN_EMAIL -ErrorAction SilentlyContinue Remove-Item Env:DEFAULT_ADMIN_PASSWORD -ErrorAction SilentlyContinue Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue Remove-Item $e2eConfigPath -Force -ErrorAction SilentlyContinue Remove-Item $e2eRunRoot -Recurse -Force -ErrorAction SilentlyContinue }