[CmdletBinding()] param( [ValidateRange(1024, 65535)] [int]$Port = 4193, [ValidateRange(10, 900)] [int]$TimeoutSeconds = 180, [string]$BrowserPath = "" ) $ErrorActionPreference = 'Stop' $projectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $serveScript = Join-Path $PSScriptRoot 'serve.ps1' $serverProcess = $null $browserProcess = $null $socket = $null $exitCode = 1 $profilePath = Join-Path ([System.IO.Path]::GetTempPath()) ("micarrsim-tests-" + [guid]::NewGuid().ToString('N')) function Resolve-BrowserPath { param([string]$RequestedPath) if (-not [string]::IsNullOrWhiteSpace($RequestedPath)) { $resolved = [System.IO.Path]::GetFullPath($RequestedPath) if (-not [System.IO.File]::Exists($resolved)) { throw "Browser executable not found: $resolved" } return $resolved } $candidates = @( 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe', 'C:\Program Files\Microsoft\Edge\Application\msedge.exe', 'C:\Program Files\Google\Chrome\Application\chrome.exe', 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe' ) foreach ($candidate in $candidates) { if ([System.IO.File]::Exists($candidate)) { return $candidate } } throw 'Microsoft Edge or Google Chrome is required to run browser tests.' } function Get-FreeLoopbackPort { $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) try { $listener.Start() return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() } } function Wait-ForHttp { param([string]$Url, [int]$Seconds) $deadline = [DateTime]::UtcNow.AddSeconds($Seconds) do { try { $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2 if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { return } } catch { Start-Sleep -Milliseconds 100 } } while ([DateTime]::UtcNow -lt $deadline) throw "Timed out waiting for $Url" } function Send-CdpMessage { param([System.Net.WebSockets.ClientWebSocket]$Client, [hashtable]$Message) $json = $Message | ConvertTo-Json -Compress -Depth 10 [byte[]]$bytes = [System.Text.Encoding]::UTF8.GetBytes($json) $segment = [System.ArraySegment[byte]]::new($bytes) $Client.SendAsync( $segment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, [System.Threading.CancellationToken]::None ).GetAwaiter().GetResult() | Out-Null } function Receive-CdpMessage { param([System.Net.WebSockets.ClientWebSocket]$Client) $buffer = New-Object byte[] 65536 $stream = [System.IO.MemoryStream]::new() try { do { $segment = [System.ArraySegment[byte]]::new($buffer) $received = $Client.ReceiveAsync( $segment, [System.Threading.CancellationToken]::None ).GetAwaiter().GetResult() if ($received.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) { throw 'Browser DevTools connection closed before tests completed.' } $stream.Write($buffer, 0, $received.Count) } while (-not $received.EndOfMessage) return [System.Text.Encoding]::UTF8.GetString($stream.ToArray()) | ConvertFrom-Json } finally { $stream.Dispose() } } try { $browser = Resolve-BrowserPath $BrowserPath $debugPort = Get-FreeLoopbackPort $serverUrl = "http://127.0.0.1:$Port/" $testUrl = "${serverUrl}tests/" $serverArguments = @( '-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $serveScript + '"'), '-Port', [string]$Port, '-NoBrowser' ) $serverProcess = Start-Process -FilePath 'powershell.exe' -ArgumentList $serverArguments -WindowStyle Hidden -PassThru Wait-ForHttp -Url $testUrl -Seconds 15 [System.IO.Directory]::CreateDirectory($profilePath) | Out-Null $browserArguments = @( '--headless=new', '--disable-gpu', '--no-first-run', '--disable-background-networking', "--remote-debugging-port=$debugPort", ('--user-data-dir="' + $profilePath + '"'), $testUrl ) $browserProcess = Start-Process -FilePath $browser -ArgumentList $browserArguments -WindowStyle Hidden -PassThru Wait-ForHttp -Url "http://127.0.0.1:$debugPort/json/version" -Seconds 15 $page = $null $pageDeadline = [DateTime]::UtcNow.AddSeconds(15) do { $pages = Invoke-RestMethod -Uri "http://127.0.0.1:$debugPort/json/list" -TimeoutSec 2 $page = $pages | Where-Object { $_.type -eq 'page' -and $_.url -like "${testUrl}*" } | Select-Object -First 1 if (-not $page) { Start-Sleep -Milliseconds 100 } } while (-not $page -and [DateTime]::UtcNow -lt $pageDeadline) if (-not $page) { throw 'Could not find the browser test page.' } $socket = [System.Net.WebSockets.ClientWebSocket]::new() $socket.ConnectAsync( [uri]$page.webSocketDebuggerUrl, [System.Threading.CancellationToken]::None ).GetAwaiter().GetResult() | Out-Null $timeoutMilliseconds = $TimeoutSeconds * 1000 $expression = @" new Promise((resolve) => { const deadline = Date.now() + $timeoutMilliseconds; const poll = () => { const status = document.documentElement.dataset.testStatus; if (status || Date.now() >= deadline) { resolve({ status: status || 'timeout', summary: document.querySelector('#test-summary')?.textContent || 'No test summary', failures: [...document.querySelectorAll('.test-result--fail')].map((item) => item.innerText), }); return; } setTimeout(poll, 100); }; poll(); }) "@ Send-CdpMessage -Client $socket -Message @{ id = 1 method = 'Runtime.evaluate' params = @{ expression = $expression; awaitPromise = $true; returnByValue = $true } } $response = $null do { $candidate = Receive-CdpMessage -Client $socket if ($candidate.id -eq 1) { $response = $candidate } } while (-not $response) if ($response.error) { throw ($response.error | ConvertTo-Json -Compress -Depth 8) } if ($response.result.exceptionDetails) { throw ($response.result.exceptionDetails | ConvertTo-Json -Compress -Depth 8) } $testResult = $response.result.result.value Write-Host $testResult.summary foreach ($failure in @($testResult.failures)) { Write-Host $failure -ForegroundColor Red } if ($testResult.status -eq 'pass') { Write-Host 'Browser tests passed.' -ForegroundColor Green $exitCode = 0 } elseif ($testResult.status -eq 'timeout') { Write-Host "Browser tests did not finish within $TimeoutSeconds seconds." -ForegroundColor Red } else { Write-Host 'Browser tests failed.' -ForegroundColor Red } } catch { Write-Error $_ } finally { if ($socket) { try { if ($socket.State -eq [System.Net.WebSockets.WebSocketState]::Open) { $socket.CloseAsync( [System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, 'test complete', [System.Threading.CancellationToken]::None ).GetAwaiter().GetResult() | Out-Null } } catch { } $socket.Dispose() } foreach ($process in @($browserProcess, $serverProcess)) { if ($process -and -not $process.HasExited) { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } } Start-Sleep -Milliseconds 200 $temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar $resolvedProfile = [System.IO.Path]::GetFullPath($profilePath) if ( $resolvedProfile.StartsWith($temporaryRoot, [System.StringComparison]::OrdinalIgnoreCase) -and [System.IO.Path]::GetFileName($resolvedProfile).StartsWith('micarrsim-tests-', [System.StringComparison]::Ordinal) ) { Remove-Item -LiteralPath $resolvedProfile -Recurse -Force -ErrorAction SilentlyContinue } } exit $exitCode