Files

398 lines
10 KiB
PowerShell
Raw Permalink Normal View History

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
}