param( [int]$Port = 0, [string[]]$Command = @('node', './scripts/run-cdp-smoke.mjs') ) $ErrorActionPreference = 'Stop' if (-not $Command -or $Command.Count -eq 0) { throw 'Command must not be empty' } 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 = 60, [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 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 Resolve-BrowserPath { if ($env:E2E_BROWSER_PATH) { return $env:E2E_BROWSER_PATH } if ($env:CHROME_HEADLESS_SHELL_PATH) { return $env:CHROME_HEADLESS_SHELL_PATH } if ($env:PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) { return $env:PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } $baseDir = Join-Path $env:LOCALAPPDATA 'ms-playwright' $candidate = Get-ChildItem $baseDir -Directory -Filter 'chromium_headless_shell-*' | Sort-Object Name -Descending | Select-Object -First 1 if ($candidate) { return (Join-Path $candidate.FullName 'chrome-headless-shell-win64\chrome-headless-shell.exe') } foreach ($fallback in @( 'C:\Program Files\Google\Chrome\Application\chrome.exe', 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe', 'C:\Program Files\Microsoft\Edge\Application\msedge.exe', 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' )) { if (Test-Path $fallback) { return $fallback } } throw 'No compatible browser found; set E2E_BROWSER_PATH or CHROME_HEADLESS_SHELL_PATH explicitly if needed' } function Test-HeadlessShellBrowser { param( [Parameter(Mandatory = $true)][string]$BrowserPath ) return [System.IO.Path]::GetFileName($BrowserPath).ToLowerInvariant().Contains('headless-shell') } function Get-BrowserArguments { param( [Parameter(Mandatory = $true)][string]$BrowserPath, [Parameter(Mandatory = $true)][int]$Port, [Parameter(Mandatory = $true)][string]$ProfileDir ) $arguments = @( "--remote-debugging-port=$Port", "--user-data-dir=$ProfileDir", '--no-sandbox' ) if (Test-HeadlessShellBrowser -BrowserPath $BrowserPath) { $arguments += '--single-process' } else { $arguments += @( '--disable-dev-shm-usage', '--disable-background-networking', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-sync', '--headless=new' ) } $arguments += 'about:blank' return $arguments } function Get-BrowserProcessIds { param( [Parameter(Mandatory = $true)][string]$BrowserPath ) $processName = [System.IO.Path]::GetFileNameWithoutExtension($BrowserPath) try { return @(Get-Process -Name $processName -ErrorAction Stop | Select-Object -ExpandProperty Id) } catch { return @() } } function Get-BrowserProcessesByProfile { param( [Parameter(Mandatory = $true)][string]$BrowserPath, [Parameter(Mandatory = $true)][string]$ProfileDir ) $processFileName = [System.IO.Path]::GetFileName($BrowserPath) $profileFragment = $ProfileDir.ToLowerInvariant() try { return @( Get-CimInstance Win32_Process -Filter ("Name = '{0}'" -f $processFileName) -ErrorAction Stop | Where-Object { $commandLine = $_.CommandLine $commandLine -and $commandLine.ToLowerInvariant().Contains($profileFragment) } ) } catch { return @() } } function Get-ChildProcessIds { param( [Parameter(Mandatory = $true)][int]$ParentId ) $pending = [System.Collections.Generic.Queue[int]]::new() $seen = [System.Collections.Generic.HashSet[int]]::new() $pending.Enqueue($ParentId) while ($pending.Count -gt 0) { $currentParentId = $pending.Dequeue() try { $children = @(Get-CimInstance Win32_Process -Filter ("ParentProcessId = {0}" -f $currentParentId) -ErrorAction Stop) } catch { $children = @() } foreach ($child in $children) { if ($seen.Add([int]$child.ProcessId)) { $pending.Enqueue([int]$child.ProcessId) } } } return @($seen) } function Get-BrowserCleanupIds { param( [Parameter(Mandatory = $true)]$Handle ) $ids = [System.Collections.Generic.HashSet[int]]::new() if ($Handle.Process) { $null = $ids.Add([int]$Handle.Process.Id) foreach ($childId in Get-ChildProcessIds -ParentId $Handle.Process.Id) { $null = $ids.Add([int]$childId) } } foreach ($processInfo in Get-BrowserProcessesByProfile -BrowserPath $Handle.BrowserPath -ProfileDir $Handle.ProfileDir) { $null = $ids.Add([int]$processInfo.ProcessId) } $liveIds = @() foreach ($processId in $ids) { try { Get-Process -Id $processId -ErrorAction Stop | Out-Null $liveIds += $processId } catch { # Process already exited. } } return @($liveIds | Sort-Object -Unique) } function Start-BrowserProcess { param( [Parameter(Mandatory = $true)][string]$BrowserPath, [Parameter(Mandatory = $true)][int]$Port, [Parameter(Mandatory = $true)][string]$ProfileDir ) $baselineIds = Get-BrowserProcessIds -BrowserPath $BrowserPath $arguments = Get-BrowserArguments -BrowserPath $BrowserPath -Port $Port -ProfileDir $ProfileDir $stdoutPath = Join-Path $ProfileDir 'browser-stdout.log' $stderrPath = Join-Path $ProfileDir 'browser-stderr.log' Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue $process = Start-Process ` -FilePath $BrowserPath ` -ArgumentList $arguments ` -PassThru ` -WindowStyle Hidden ` -RedirectStandardOutput $stdoutPath ` -RedirectStandardError $stderrPath return [pscustomobject]@{ BrowserPath = $BrowserPath BaselineIds = $baselineIds ProfileDir = $ProfileDir Process = $process StdOut = $stdoutPath StdErr = $stderrPath } } function Show-BrowserLogs { param( [Parameter(Mandatory = $false)]$Handle ) if (-not $Handle) { return } foreach ($path in @($Handle.StdOut, $Handle.StdErr)) { if (-not [string]::IsNullOrWhiteSpace($path) -and (Test-Path $path)) { Get-Content $path -ErrorAction SilentlyContinue } } } function Stop-BrowserProcess { param( [Parameter(Mandatory = $false)]$Handle ) if (-not $Handle) { return } if ($Handle.Process -and -not $Handle.Process.HasExited) { foreach ($cleanupCommand in @( { param($id) taskkill /PID $id /T /F *> $null }, { param($id) Stop-Process -Id $id -Force -ErrorAction Stop } )) { try { & $cleanupCommand $Handle.Process.Id } catch { # Ignore cleanup errors here; the residual PID check below is authoritative. } } } $residualIds = @() for ($attempt = 0; $attempt -lt 12; $attempt++) { $residualIds = @(Get-BrowserCleanupIds -Handle $Handle) foreach ($processId in $residualIds) { foreach ($cleanupCommand in @( { param($id) taskkill /PID $id /T /F *> $null }, { param($id) Stop-Process -Id $id -Force -ErrorAction Stop } )) { try { & $cleanupCommand $processId } catch { # Ignore per-process cleanup errors during retry loop. } } } Start-Sleep -Milliseconds 500 $residualIds = @(Get-BrowserCleanupIds -Handle $Handle) if ($residualIds.Count -eq 0) { break } } if ($residualIds.Count -gt 0) { throw "browser cleanup leaked PIDs: $($residualIds -join ', ')" } } function Remove-BrowserLogs { param( [Parameter(Mandatory = $false)]$Handle ) if (-not $Handle) { return } $paths = @($Handle.StdOut, $Handle.StdErr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($paths.Count -gt 0) { Remove-Item $paths -Force -ErrorAction SilentlyContinue } } $browserPath = Resolve-BrowserPath Write-Host "CDP browser: $browserPath" $Port = if ($Port -gt 0) { $Port } else { Get-FreeTcpPort } $profileRoot = Join-Path (Resolve-Path (Join-Path $PSScriptRoot '..')).Path '.cache\cdp-profiles' New-Item -ItemType Directory -Force $profileRoot | Out-Null $profileDir = Join-Path $profileRoot "pw-profile-cdp-smoke-win-$Port" $browserReadyUrl = "http://127.0.0.1:$Port/json/version" $browserCdpBaseUrl = "http://127.0.0.1:$Port" $browserHandle = $null try { for ($attempt = 1; $attempt -le 2; $attempt++) { Remove-Item -Recurse -Force $profileDir -ErrorAction SilentlyContinue $browserHandle = Start-BrowserProcess -BrowserPath $browserPath -Port $Port -ProfileDir $profileDir try { Wait-UrlReady -Url $browserReadyUrl -Label "browser CDP endpoint (attempt $attempt)" Write-Host "CDP endpoint ready: $browserReadyUrl" break } catch { Show-BrowserLogs $browserHandle Stop-BrowserProcess $browserHandle Remove-BrowserLogs $browserHandle $browserHandle = $null if ($attempt -eq 2) { throw } } } if (-not $env:E2E_COMMAND_TIMEOUT_MS) { $env:E2E_COMMAND_TIMEOUT_MS = '120000' } $env:E2E_SKIP_BROWSER_LAUNCH = '1' $env:E2E_CDP_PORT = "$Port" $env:E2E_CDP_BASE_URL = $browserCdpBaseUrl $env:E2E_PLAYWRIGHT_CDP_URL = $browserCdpBaseUrl $env:E2E_EXTERNAL_CDP = '1' $commandName = $Command[0] $commandArgs = @() if ($Command.Count -gt 1) { $commandArgs = $Command[1..($Command.Count - 1)] } Write-Host "Launching command: $commandName $($commandArgs -join ' ')" & $commandName @commandArgs if ($LASTEXITCODE -ne 0) { throw "command failed with exit code $LASTEXITCODE" } } finally { Stop-BrowserProcess $browserHandle Remove-BrowserLogs $browserHandle Remove-Item Env:E2E_SKIP_BROWSER_LAUNCH -ErrorAction SilentlyContinue Remove-Item Env:E2E_CDP_PORT -ErrorAction SilentlyContinue Remove-Item Env:E2E_CDP_BASE_URL -ErrorAction SilentlyContinue Remove-Item Env:E2E_PLAYWRIGHT_CDP_URL -ErrorAction SilentlyContinue Remove-Item Env:E2E_EXTERNAL_CDP -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $profileDir -ErrorAction SilentlyContinue }